fix(auth): stateless session cookie — stop logging users out on deploy (ADR-029)
Pilot feedback: lots of re-logging-in on iPhone. Three causes: sessions lived in an in-memory map (wiped on every pod restart/deploy), a 1h TTL (idle >1h forced re-login on a check-back-tomorrow reader), and a session cookie with no Max-Age (dropped on Safari close). Each re-login is the full IdP redirect dance. Make sessions stateless: identity + absolute expiry live inside the existing HMAC-signed cookie (no server table), TTL 1h → 30 days sliding, cookie now persistent (Max-Age). Survives restarts (test: a cookie from one instance is accepted by a fresh instance with the same secret), browser-close, and idle. Trade: no server-side revocation — logout clears the cookie client-side; rotating tapir-session-secret is the global logout lever. Accepted for the Stage-0 reader. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1128,6 +1128,40 @@ Spec: `docs/specs/onboarding-wow-burst.md`.
|
||||
|
||||
---
|
||||
|
||||
## ADR-029 — Stateless session cookie (survives restarts, browser-close, idle)
|
||||
|
||||
**Status:** Accepted (2026-06-11). Triggered by pilot feedback: "lots of clicking to log in again
|
||||
on iPhone." Supersedes the in-memory session store in the ADR-011 login.
|
||||
|
||||
**Context.** Three compounding causes made users re-login constantly:
|
||||
1. **In-memory session store** (`sessionStore` map) — wiped on every pod restart, so each deploy
|
||||
logged everyone out. During the active build period that was ~15 logouts.
|
||||
2. **1-hour session TTL** — for a "check back tomorrow" reader, idle > 1h forced a re-login on
|
||||
nearly every visit.
|
||||
3. **No cookie Max-Age** — a session cookie (deleted on browser/app close); iPhone Safari closing
|
||||
the tab dropped it.
|
||||
Each re-login is the full Dex/Authentik redirect dance — many taps on mobile.
|
||||
|
||||
**Decision.** Make the session **stateless**: the identity (subject + email) and an absolute
|
||||
expiry live INSIDE the existing HMAC-signed (HS256) cookie — no server-side table. Plus:
|
||||
- **30-day sliding TTL** (was 1h), re-signed on each request so an active user never lapses.
|
||||
- **Persistent cookie** (`Max-Age` set) so it survives browser/app close.
|
||||
The cookie is HttpOnly + Secure + SameSite=Lax; the HMAC (keyed by the stable ESO
|
||||
`tapir-session-secret`, which does NOT rotate per deploy) makes it tamper-proof. The payload is
|
||||
identity, not secrets — the OIDC access/ID tokens are still discarded after callback.
|
||||
|
||||
**Consequences.** A deploy/restart no longer logs anyone out (proven by a test: a cookie issued by
|
||||
one instance is accepted by a fresh instance with the same secret); works across replicas for
|
||||
free. **Trade:** no server-side revocation — `logout` clears the cookie client-side, but a copied
|
||||
cookie stays valid until expiry. Accepted for the Stage-0 reader pilot; revisit (server-side
|
||||
revocation list, or shorter TTL + refresh) if it ever holds sensitive actions. Rotating
|
||||
`tapir-session-secret` invalidates all sessions — the global logout lever.
|
||||
|
||||
**Not addressed here:** the tap-count of the IdP login page itself is Authentik's UX; with
|
||||
re-login now rare (30-day idle or explicit logout), it matters far less.
|
||||
|
||||
---
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
Approaches considered during the 2026-06-02 planning + grill session and **deliberately not
|
||||
|
||||
Reference in New Issue
Block a user