Documentation
¶
Overview ¶
Package auth manages bearer tokens: generation, hashed storage, and request-time validation.
Tokens are 256-bit random values, base64url-encoded without padding (43 chars). The store persists only SHA-256 hashes — a stolen tokens.json cannot be used to construct a working token. Token IDs are the first 12 hex chars of the hash, stable and unique enough for a household-sized device set.
The store is concurrent-safe within a single process, and survives out-of- process writes (e.g. `bridge pair` running while `bridge serve` is up): on each Validate, Store stats the tokens file and reloads if the mtime has advanced. Writes use atomic rename so a reader never sees a torn file.
Index ¶
- Variables
- type Store
- func (s *Store) FlushLastUsed() error
- func (s *Store) Get(id string) (Token, error)
- func (s *Store) List() []Token
- func (s *Store) Mint(name string) (rawToken string, tok Token, err error)
- func (s *Store) RecordClientVersion(id, ver string)
- func (s *Store) Revoke(id string) error
- func (s *Store) Rotate(id string) (rawToken string, tok Token, err error)
- func (s *Store) SetExpiry(id string, expiresAt *time.Time) (Token, error)
- func (s *Store) Validate(rawToken string) (Token, bool)
- type Token
Constants ¶
This section is empty.
Variables ¶
var ErrNotFound = errors.New("token not found")
ErrNotFound is returned by Revoke / Rotate / SetExpiry when the given ID is unknown.
Functions ¶
This section is empty.
Types ¶
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store is an in-memory view over a JSON-backed token file. Safe for concurrent use by readers (Validate) and writers (Mint / Revoke).
func OpenStore ¶
OpenStore opens (or initializes an empty) store at path. Missing file is not an error — the first Mint will create it.
func (*Store) FlushLastUsed ¶
FlushLastUsed forces a persist of any in-memory LastUsedAt updates that the debounce in Validate has not yet written. Call on clean shutdown so a just-before-exit validate doesn't lose its timestamp. persist() itself updates `lastUsedFlush`, so nothing else to do here.
Cross-process safety: a sibling `bridge pair` / `bridge revoke` may have rewritten tokens.json since this process last loaded it. If no authenticated request followed (Validate is what triggers the routine reloadIfStale), persisting the stale in-memory slice here would silently delete the freshly-minted token (or resurrect a revoked one) at shutdown. reload's per-token merge preserves the in-memory LastUsedAt / LastClientVersion bumps this flush exists to land, so the reload can't lose them. A reload failure aborts the flush — dropping a debounced timestamp is recoverable; an overwrite that deletes a sibling's token is not.
func (*Store) Get ¶ added in v0.1.1
Get returns the token matching id, or ErrNotFound if none exists. The returned struct is a copy — mutating it has no effect on the store. Used by the admin token-lifecycle handlers as a cheap single-row lookup vs. the O(N) `List()`-then-scan pattern Gemini flagged on PR #45 review.
func (*Store) List ¶
List returns a copy of the stored tokens (hashes only — raw tokens cannot be recovered from the store).
func (*Store) Mint ¶
Mint creates a new token with the given human-readable name (e.g. "iPhone 15 Pro"), persists the hash, and returns both the raw token (show once, to the user) and the stored record. Names need not be unique.
func (*Store) RecordClientVersion ¶ added in v0.1.1
RecordClientVersion stores the iOS app version a client identified itself as via the X-Client-Version request header. Called from the authed() middleware on every authenticated request whose X-Client-Version is non-empty AND differs from the value the middleware's token-copy already shows (the cheap pre-check happens in api.authed; this method always re-checks under the mutex).
Persistence honours the same 30-second `lastUsedFlush` debounce as LastUsedAt updates. Without that gate, a misbehaving or malicious client could rotate its X-Client-Version on every request and force synchronous tokens.json rewrites under the global lock — a DoS vector against every other authenticated request. Bounded to one persist per 30 s, the in-memory state still tracks the latest value (so the updater's compat gate sees fresh data) and the shutdown FlushLastUsed call lands any deferred update on disk.
id is the token ID returned by Validate. version is the raw header value; whitespace is trimmed and over-long values are truncated to 64 chars (defence against a misbehaving client filling the header with junk and ballooning tokens.json).
No-op when id or version is empty (e.g. an old iOS client that doesn't send the header).
func (*Store) Revoke ¶
Revoke removes the token with the given ID. Returns ErrNotFound if no such token exists.
func (*Store) Rotate ¶ added in v0.1.1
Rotate replaces the raw bytes of an existing token, returning the new raw token for re-pairing. The token's ID, Name, CreatedAt, and ExpiresAt are preserved across rotation; only Hash and RotatedAt change. The previous raw token stops validating immediately.
This is the operator path for "this token was leaked / I want a fresh secret without losing the row identity". Pairs with iOS's existing "scan a fresh QR" re-pair flow — the operator hands the new raw to the device-holder, who scans it from the admin console's pair URL or types it into the Bridge Editor.
Returns ErrNotFound if no token with that ID exists.
func (*Store) SetExpiry ¶ added in v0.1.1
SetExpiry installs (or clears) the ExpiresAt field for an existing token. Pass nil to remove an existing expiry. Returns ErrNotFound if no token with that ID exists.
Validation is permissive about backwards-set expiries — passing a past timestamp immediately invalidates the token (operator "expire this now" path). The CLI surfaces a `--in <duration>` flag that resolves to `time.Now().Add(d)`; admin UI can pass any wall-clock RFC3339.
func (*Store) Validate ¶
Validate checks a raw token against the store. Returns the matching Token and true on a hit, a zero Token and false on miss. Uses constant-time hash comparison.
On a hit Validate updates LastUsedAt in memory and persists lazily — at most once per lastUsedFlushInterval — so a busy request path doesn't rewrite tokens.json on every hit. A persist failure is logged and ignored because the primary work (validation) already succeeded; log visibility ensures silent disk issues don't go unnoticed.
type Token ¶
type Token struct {
ID string `json:"id"`
Name string `json:"name"`
Hash string `json:"hash"` // SHA-256 hex of the raw token bytes
CreatedAt time.Time `json:"createdAt"`
LastUsedAt time.Time `json:"lastUsedAt,omitempty"`
LastClientVersion string `json:"lastClientVersion,omitempty"`
LastClientVersionAt time.Time `json:"lastClientVersionAt,omitempty"`
// RotatedAt is set by Rotate when the raw bytes are replaced
// (Hash gets a new value, ID/Name/CreatedAt stay). Zero means
// the token has never been rotated. After rotation, the
// "ID = first 12 hex chars of Hash" invariant from Mint no
// longer holds for this row — that's a deliberate UX trade so
// the operator's reference (admin URL, log line, runbook) stays
// stable across a rotation. Mint still derives the ID from the
// hash for new tokens.
RotatedAt time.Time `json:"rotatedAt,omitempty"`
// ExpiresAt is the optional hard cutoff. nil/absent means "never
// expires" (the historical behaviour). Validate rejects with
// ErrExpired once the wall-clock crosses ExpiresAt — ahead of
// the constant-time hash compare so a leaked-but-expired raw
// token can't be used. Stored as `*time.Time` to distinguish
// "operator cleared the expiry" (nil) from "never set" (omitted)
// across YAML/JSON round-trips.
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
}
Token is the stored, hash-only record for a paired client.
LastClientVersion records the most recent X-Client-Version header value this token presented (additive in protocol v1; absent on older clients). LastClientVersionAt is the "last *changed*" timestamp — when the value first transitioned to whatever LastClientVersion currently holds — NOT a per-request "last seen" marker. That keeps RecordClientVersion's hot path lock-free under steady-state traffic (same value, no field write needed). Use LastUsedAt for "when did this token last present credentials" and LastClientVersionAt for "when did its self-reported version most recently change".
The auto-installer's compat gate (Phase C) reads LastClientVersion to decide whether a candidate update would orphan a still-active iOS build.