App reference

ADR-0017: Device sessions with rotating refresh tokens

Metadata

  • Status: Accepted
  • Date: 2026-08-05 (records a change that landed 2026-08-05; written after the fact β€” the process gap this repairs is itself a rule-4 finding)
  • Deciders: EagraΓ­ Clainne Team
  • Related: ADR-0007 (JWT authentication); ADR-0016 (MCP server and API keys β€” the machine-principal counterpart to these human sessions)

Context

ADR-0007 left EagraΓ­ Clainne with exactly one human credential: a short-lived session JWT (12 hours by default, settings.TokenExpiry). That shape forced three problems at once:

  • Re-login on every expiry. The Android app (and any browser that wants "remember this device") had no way to stay signed in without storing the password on the device.
  • All-or-nothing revocation. The only kill switch was the user's token_generation bump, which orphans every token the account holds β€” there was no way to sign out one stolen phone and keep the kitchen tablet.
  • No visibility. Nobody could see which devices held a working credential.

A long-lived credential needs server-side state (a self-contained JWT cannot be revoked per device), which makes this an auth, schema and service-shape change β€” the ADR trio.

Decision

  1. A session table stores refresh-token records. One JSONB row per token (protojson, ADR-0001 conventions), holding user_uid, a display label, token_hash, chain_uid, created_at, last_used_at, revoked_at and successor_uid. The token itself is 32 bytes of randomness, base64url; storage keeps only its SHA-256 hex digest β€” no salt or work factor, because the input already carries 256 bits of entropy. token_hash joins the SanitizeInterceptor scrub map, so no response can leak a digest.

  2. A device session is a chain, and rotation is mandatory. Every RefreshSession exchange revokes the presented record and mints a successor under the same chain_uid, so exactly one record per living chain is exchangeable. Revoked records are kept: a rotated-out token presented again is evidence, not noise.

  3. Reuse kills the chain; a short grace absorbs client races. A rotated token replayed within 30 seconds (auth.RefreshReuseGrace) is a race loser β€” browser tabs share one credential store β€” and exchanges normally, minting a sibling head, provided the chain still has a live head (the grace can never resurrect a revoked chain). Past the grace, the replay means the plaintext leaked: the whole chain is revoked, cutting off the thief and the victim alike, and either party must sign in again. An explicitly revoked record (no successor) refuses on the spot. Every refusal path returns the same Unauthenticated "invalid refresh token", so the response never reveals which check failed.

  4. Idle expiry bounds unattended sessions. A chain unused for 90 days (auth.RefreshTokenIdleExpiry) refuses and revokes itself β€” a device that has been away that long signs in again.

  5. Every exchange re-makes Login's account checks. The user record is read inside the exchange transaction; a deleted or remembered (domain.CanSignIn) account revokes the chain there and then. Exchanges run under FindAllByForUpdate row locks, so two racing exchanges of one token resolve to one rotation and one refusal, never two heads.

  6. The service surface grows four seams on UserService:

    • Login with remember_device (plus device_label) starts a chain and returns the first refresh token.
    • RefreshSession exchanges a refresh token for a fresh access token and the chain's next refresh token. It is a public endpoint β€” the access token is typically expired when a client calls it, so possession of the refresh token is the whole proof. The PermissionMatrix records it alongside SetupStatus, Login and Logout.
    • ListSessions shows the requester one entry per living chain, hash scrubbed, newest first.
    • RevokeSession kills one of the requester's own chains, idempotently. Naming another member's chain is NotFound, not PermissionDenied β€” chain uids are private.
    • Logout (already public) additionally revokes the presented token's chain, best-effort: possession is the same authority RefreshSession accepts.
  7. Transport differs by client, decided per request. Go/CLI/Android clients read tokens from the response body and send bearers. The web app declares X-Auth-Mode: cookie and gets both tokens as httpOnly cookies instead, with the body copies blanked β€” page scripts never see either credential. The refresh cookie is path-scoped to the UserService procedures, so browsers attach it to RefreshSession and Logout and nothing else.

  8. Password changes end every session. ChangePassword and the admin SetPassword call revokeAllUserSessions β€” whoever holds the old credential (including the thief the change was aimed at) stops refreshing immediately.

  9. Session records live outside the svc mutation seams, deliberately. They are credential bookkeeping, not household data: the Session message carries no mutations trail, the entity is not announced on the changes stream, and writes happen directly inside the exchange transactions. The seams exist to give domain entities an audit story; granting a token an audit trail of itself would only spread digests around.

Consequences

Positive

  • Per-device sign-out. One stolen phone dies without touching the other devices; Settings lists the devices and offers the button (web and Android).
  • Android stays signed in without storing the password β€” the M0 milestone this unblocked.
  • Theft has a tripwire. Token reuse past the grace window revokes the chain, so a copied token buys an attacker at most one rotation before either party's next refresh exposes it.
  • The password remains the only long-term secret, and it already had a revocation story; refresh tokens are random, hashed at rest and scrubbed from every response structurally.

Negative / trade-offs

  • Access tokens outlive revocation. Revoking a chain stops future refreshes; the current access token stays valid until its expiry (up to 12 hours). Accepted: the access-token TTL bounds the exposure, and the token_generation bump remains the hard kill.
  • The grace window is a real 30-second replay allowance. A thief who uses a stolen token within 30 seconds of the victim's own rotation mints a sibling head silently. Accepted as the price of not signing out every multi-tab browser; the window only works while the chain is alive.
  • Revoked records accumulate. Chains keep their history for the reuse check; storage is bounded in practice by the 90-day idle sweep being a refusal (records of dead chains linger until manually pruned β€” a future retention pass may tidy them).
  • A second credential type exists. Every auth-adjacent reviewer must now hold both models: short JWT (stateless, tgen-checked) and refresh chain (stateful, rotation-checked). ADR-0016's API keys make it three.

Alternatives considered

  • Long-lived JWTs as refresh tokens. Rejected: self-contained tokens cannot be revoked per device without a server-side denylist, which is the session table with worse ergonomics.
  • One static refresh token per device (no rotation). Rejected: a copied token would be indistinguishable from the device forever; rotation is what turns reuse into a detectable event.
  • Sliding token_generation for revocation. Rejected: it is account-global by design and cannot express "sign out that one phone".
  • Storing refresh tokens in web localStorage. Rejected: XSS reads localStorage; httpOnly path-scoped cookies keep both credentials out of page scripts entirely, matching the ADR-0007 hardening direction.
  • No reuse grace. Rejected: two browser tabs share one cookie jar and can race an exchange, so a zero-grace design would let a household kill its own chain in normal use. Thirty seconds absorbs the race while keeping the theft tripwire meaningful.
  • Auditing session writes through svc.Mutate. Rejected: sessions are credentials, not household history; an audit trail on token records would copy digests into the audit log for no household-visible benefit.