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_generationbump, 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
-
A
sessiontable stores refresh-token records. One JSONB row per token (protojson, ADR-0001 conventions), holdinguser_uid, a displaylabel,token_hash,chain_uid,created_at,last_used_at,revoked_atandsuccessor_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_hashjoins theSanitizeInterceptorscrub map, so no response can leak a digest. -
A device session is a chain, and rotation is mandatory. Every
RefreshSessionexchange revokes the presented record and mints a successor under the samechain_uid, so exactly one record per living chain is exchangeable. Revoked records are kept: a rotated-out token presented again is evidence, not noise. -
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 sameUnauthenticated"invalid refresh token", so the response never reveals which check failed. -
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. -
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 underFindAllByForUpdaterow locks, so two racing exchanges of one token resolve to one rotation and one refusal, never two heads. -
The service surface grows four seams on
UserService:Loginwithremember_device(plusdevice_label) starts a chain and returns the first refresh token.RefreshSessionexchanges 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. ThePermissionMatrixrecords it alongside SetupStatus, Login and Logout.ListSessionsshows the requester one entry per living chain, hash scrubbed, newest first.RevokeSessionkills one of the requester's own chains, idempotently. Naming another member's chain isNotFound, notPermissionDeniedβ chain uids are private.Logout(already public) additionally revokes the presented token's chain, best-effort: possession is the same authorityRefreshSessionaccepts.
-
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: cookieand 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 theUserServiceprocedures, so browsers attach it toRefreshSessionandLogoutand nothing else. -
Password changes end every session.
ChangePasswordand the adminSetPasswordcallrevokeAllUserSessionsβ whoever holds the old credential (including the thief the change was aimed at) stops refreshing immediately. -
Session records live outside the
svcmutation seams, deliberately. They are credential bookkeeping, not household data: theSessionmessage carries nomutationstrail, 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_generationbump 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_generationfor 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.