Handler/migration counts, calculateBackoff -> calculateDelay, machine-id binding (cross-platform machineid + fallbacks, not hostname), last-reviewed dates, and made [[cross-references]] relative so they resolve.
3.5 KiB
Refresh Token Lifecycle
Forward-only token rotation with family revocation — a stolen config is a dead config.
Overview
Agents authenticate with short-lived JWTs minted against a long-lived refresh token (90-day TTL). Every renewal rotates the refresh token: a successor is minted, the old token is marked consumed. The rotation lineage is the security mechanism — replaying a consumed token is how theft announces itself.
Cross-references:
- security/02-authentication-stack (where this sits in the four-layer stack)
- security/04-machine-binding (the renewal endpoint is machine-bound)
Data Model
Migration 045. Each token row carries:
| Column | Purpose |
|---|---|
family_id |
Lineage identifier — all rotations of one registration share it |
consumed_at |
Set when the token is exchanged for a successor |
superseded_by |
Points at the successor token |
Tokens are stored hashed (HashRefreshToken), never plaintext. Queries live in server/internal/database/queries/refresh_tokens.go.
The Renewal Flow
RenewToken (server/internal/api/handlers/agents.go):
- Machine binding first.
X-Machine-IDis checked against the registered host before any token logic. Mismatch →403+MACHINE_ID_MISMATCHsecurity event. A stolenconfig.jsonreplayed from another machine never reaches rotation. - Locked read.
GetRefreshTokenForRenewusesSELECT ... FOR UPDATE— two concurrent renewals with the same token cannot both succeed. - Classify the presented token:
| State of presented token | Verdict | Action |
|---|---|---|
| Unconsumed, unexpired | Normal renewal | Mint successor, mark consumed |
| Consumed, successor unconsumed | Crash-recovery grace | Agent saved the old token but died before persisting the new one. Accept once; issue a fresh successor |
| Consumed, successor also consumed | Reuse = theft | Revoke the entire family_id, log security event, return terminal error |
The grace window is accept-previous-once — exactly one step back in the lineage, exactly once. Forward-only is doctrine (core/01-ethos); there is no knob to widen it.
Agent Side
- Terminal sentinel errors (
ErrRefreshTokenInvalid,ErrMachineMismatch) stop the polling loop's retry machinery — these are not transient network failures and are logged[CRITICAL]. See components/02-agent. - The instance lock exists largely for this mechanism: two agent processes sharing one
config.jsonwould race rotations and trip family revocation on themselves. One config, one process, enforced by flock/mutex.
Operational Notes
- Revoked family → agent must be re-registered with a fresh registration token. Runbook:
OPERATIONS.md §2. - After a database restore, agents may present tokens the restored DB has never seen (or sees as stale lineage). Expect re-registration; see
OPERATIONS.md §3. CleanupExpiredTokensreaps expired rows;RevokeAllAgentTokensis the operator hammer.
Why This Shape
A refresh token in a file on a fleet machine will eventually leak — backup snapshots, copied VMs, sloppy decommissioning. Rotation-with-family-revocation means a leaked token is only useful until the legitimate agent next renews, and using a stale one burns the whole family loudly. The failure mode is detection, not silent coexistence.
Last reviewed: 2026-06-14