Plan: Web Session Cookie Migration (ADR-013)

On this page

Status

COMPLETE — merged to main via !81 (2026-04-18). Plan moved to archive index; see plans/archive.adoc Security & Compliance.

Step Description Status

1

Write plan, create issue, create branch

Done (pre-ADR-030)

2

Add cookie-jar dependency with private+signed features

Done (pre-ADR-030) — see Errata — chose tower-cookies 0.11 over axum-extra

3

Add Key to AppState

Done (pre-ADR-030)

4

Create WebSession struct with cookie serialization (serde JSON, encrypted)

Done (pre-ADR-030)

5

Rewrite auth.rs: login (PKCE in cookie), callback (store session cookie), logout (clear cookie)

Done (pre-ADR-030)

6

Rewrite middleware.rs: require_auth reads cookie, injects SessionUser into extensions

Done (pre-ADR-030)

7

Update route handlers: replace Session extractor with Extension<SessionUser>

Done (pre-ADR-030) — 31 route files

8

Remove tower-sessions dependency entirely

Done (pre-ADR-030)

9

Update config: validate SESSION_SECRET >= 64 bytes

Done (pre-ADR-030)

10

Update docker-compose SESSION_SECRET to >= 64 bytes

Done (pre-ADR-030)

11

Verify: full nextest + 145 E2E pass

Done (pre-ADR-030) — 1181/1181 nextest, 145/145 E2E

12

Update docs (security.md, CHANGELOG)

Done (pre-ADR-030)

Errata

  • Cookie-jar library chosen: tower-cookies 0.11 with private+signed features rather than axum-extra::PrivateCookieJar. Both encrypt with AES-GCM using cookie::Key. tower-cookies uses a middleware layer (CookieManagerLayer) and the Cookies extractor; axum-extra uses a per-handler extractor that must be returned in the response tuple. The middleware-layer model is less invasive (handlers don’t need to change their return types) and matches the existing CookieManagerLayer already in place for flash messages. Functionally equivalent for the ADR-013 goal.

  • Cookie name: craig-session (not Host-craig-session). Host- prefix requires Secure + no Domain + Path=/, which breaks the dev stack where CRAIG_WEB__SESSION_SECURE=false. The Secure attribute is still set in production via config.

  • Transitive dep bump: rustls-webpki 0.103.9 → 0.103.12 for RUSTSEC-2026-0098 landed as a separate prerequisite commit (93e71a3) — cargo-deny rejected the old version in validate.

Issues: ADR-013
Branch: feature/private-cookie-sessions

Context

craig-web currently uses tower_sessions::MemoryStore for browser sessions. This means:

  • Sessions are lost on container restart (users must re-login)

  • No horizontal scaling (sessions pinned to one instance)

  • In-process HashMap grows unbounded with active users

ADR-013 decided on stateless encrypted cookies. Two attempts using tower-sessions-cookie-store failed due to version incompatibility between tower-sessions 0.15 and tower-sessions-cookie-store 0.5 — the Session extractor doesn’t recognize the cookie store’s layer at runtime.

The solution is axum_extra::extract::cookie::PrivateCookieJar — a first-class Axum extractor for AES-GCM encrypted cookies. No third-party session crate needed.

Scope

In scope:

  • Replace MemoryStore with PrivateCookieJar

  • Typed WebSession struct (serde JSON in encrypted cookie)

  • PKCE state in a separate short-lived cookie (not in the session)

  • Refresh token stored in session cookie

  • SessionUser injected into request extensions by middleware

  • Remove tower-sessions and tower-sessions-cookie-store dependencies

Out of scope:

  • Token refresh on expiry (follow-up work)

  • Server-side revocation blocklist (deferred to Phase 11 per ADR-013)

  • Locale persistence in cookie (keep in session cookie as a field)

Design

One encrypted cookie (__Host-craig-session):

{
  "access_token": "eyJ...",
  "refresh_token": "eyJ...",
  "username": "jane.doe",
  "sub": "00000000-0000-0000-0000-000000000001",
  "email": "jane@example.com",
  "roles": ["caseworker", "supervisor"],
  "locale": "en"
}

Encrypted with AES-GCM via axum_extra::extract::cookie::Key. Cookie attributes: HttpOnly, SameSite=Lax, Secure (configurable), Path=/, Max-Age from config.

One plain cookie during OIDC flow only (craig-pkce, short-lived, cleared after callback):

{
  "verifier": "...",
  "state": "..."
}

Signed (not encrypted) since PKCE verifier is a random nonce — no sensitive data.

Key Architecture Changes

  1. AppState gains a cookie_key: axum_extra::extract::cookie::Key field

  2. Key implements FromRef<AppState> so PrivateCookieJar can extract it

  3. Auth handlers return (PrivateCookieJar, impl IntoResponse) tuples — the jar must be in the response for Set-Cookie to fire

  4. require_auth middleware reads the cookie, deserializes WebSession, injects Extension<SessionUser> — route handlers extract Extension<SessionUser> unchanged

  5. Route handlers that currently extract Session only use it for get_session_user() — the middleware handles this, so handlers just use Extension<SessionUser>

Files Modified

File Change

services/craig-web/Cargo.toml

Add axum-extra = { version = "0.12", features = ["cookie-private"] }, remove tower-sessions

services/craig-web/src/state.rs

Add cookie_key: Key to AppState, implement FromRef<AppState> for Key

services/craig-web/src/auth.rs

Rewrite login/callback/logout to use PrivateCookieJar. Add WebSession struct. Remove tower_sessions::Session usage.

services/craig-web/src/middleware.rs

Rewrite require_auth to read PrivateCookieJar, deserialize WebSession, inject Extension<SessionUser>

services/craig-web/src/main.rs

Remove SessionManagerLayer. Build Key from SESSION_SECRET. Pass to AppState. Remove tower-sessions imports.

services/craig-web/src/config.rs

Add session_max_age_secs field (default 1800). Add cookie_key() method that validates length and derives Key.

services/craig-web/src/routes/*.rs (all)

Replace session: Session parameters with Extension<SessionUser> where used for auth only. For locale: read from SessionUser.locale.

Cargo.toml (workspace)

Remove tower-sessions, tower-sessions-cookie-store from workspace deps. Add axum-extra.

docker-compose.yml

Extend SESSION_SECRET to >= 64 bytes

.claude/docs/security.md

Update session management section

CHANGELOG.adoc

Document migration

Verification

  1. cargo build -p craig-web — zero errors

  2. cargo clippy -p craig-web — -D warnings — zero warnings

  3. cargo nextest run --workspace --profile integration — all 1186 pass

  4. cargo xtask e2e with seed=42 — 145 pass

  5. cargo xtask e2e with seed=9999 — 145 pass

  6. cargo xtask e2e with seed=5555 — 145 pass

  7. Browser manual test: login → navigate → restart craig-web container → refresh page → still logged in (session survives restart)

Edit this page · latest