ADR-0033: Chain-bound session tokens
Metadata
- Status: Accepted
- Date: 2026-08-18
- Deciders: Eagraí Clainne Team
- Related: ADR-0019 (refresh-token session chains), ADR-0020 (switch-user and PIN), issue #112
Context
A device session is a chain of refresh-token records (ADR-0019). Revoking
the chain — RevokeSession from the settings page, Logout, a password
change — stops the device from refreshing. But the access JWT the device
already holds stays valid until its embedded expiry (up to 12 hours). The
member presses "Sign out" on a device, the server confirms, and the device
keeps working for hours. Issue #112 reports exactly this: "Deleting all
sessions doesn't log a user out."
The interceptor already resolves the live account on every request
(existence, roles, tgen). It had no way to consult the session chain,
because the token does not name one.
A related gap: ListSessions could not say which chain the requester is
on. The token carried no chain identity, so the settings page listed
sessions with no "this device" marker.
Decision
Session JWTs minted alongside a refresh chain carry a sid claim
naming the chain (chain_uid). Every mint site that has a chain sets it:
Login with remember_device, RefreshSession, SwitchProfile, and
RenewToken (which carries the presented token's sid forward).
The auth interceptor, after the subject and generation checks, refuses a
sid-bearing token whose chain has no live (unrevoked) record. The lookup
is a Config seam like LookupSubject — LookupChain(ctx, chainUID) (alive bool, err error) — wired by the server over the session table and
nil-tolerated so session-only tests need no wiring.
The authenticated UserContext records the chain
(Credential.ChainUID), so handlers know which chain the request rode.
ListSessions uses it to mark the requester's own chain current = true.
Tokens without a sid — minted before this change, or by a login that
did not remember the device — behave exactly as before: they die at
expiry or generation bump. No token is invalidated by deploying this.
Consequences
- Revoking a session ends that device's access on its next request, not at token expiry. "Sign out this device" now means what it says.
- Every request bearing a
sidtoken costs one extra indexed read (session records bychain_uid). At household scale this is noise; a cache can be added behind the seam if it ever matters. - The
sidclaim is bookkeeping, not authority: authorization still comes from the live account record. A forgedsidcannot widen access — it can only get the token refused. - Rotation keeps working: a rotated-out record is revoked but its chain keeps a live head, so access tokens minted before a rotation stay valid for their lifetime.
Alternatives considered
- Short access tokens plus refresh-only enforcement (status quo with a 5-minute expiry): keeps revocation latency but multiplies refresh traffic and still leaves a window; rejected.
- A denylist of revoked token IDs: needs its own table, expiry
sweeping, and a
jtiper token — strictly more machinery than reusing the session table the chains already live in; rejected. - Bumping
token_generationon revoke: orphans every session of the account, so revoking one device would sign out all of them; rejected.