Watch
1
0
Fork
You've already forked RedFlag
0
RedFlag/RAF/security/03-refresh-tokens.md
Fimeg d9ba008f67 raf: sync docs to code + relative cross-reference links
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.
2026-06-15 09:39:34 -04:00

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:


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):

  1. Machine binding first. X-Machine-ID is checked against the registered host before any token logic. Mismatch → 403 + MACHINE_ID_MISMATCH security event. A stolen config.json replayed from another machine never reaches rotation.
  2. Locked read. GetRefreshTokenForRenew uses SELECT ... FOR UPDATE — two concurrent renewals with the same token cannot both succeed.
  3. 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.json would 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.
  • CleanupExpiredTokens reaps expired rows; RevokeAllAgentTokens is 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