App reference

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 sid token costs one extra indexed read (session records by chain_uid). At household scale this is noise; a cache can be added behind the seam if it ever matters.
  • The sid claim is bookkeeping, not authority: authorization still comes from the live account record. A forged sid cannot 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 jti per token — strictly more machinery than reusing the session table the chains already live in; rejected.
  • Bumping token_generation on revoke: orphans every session of the account, so revoking one device would sign out all of them; rejected.