Implement web session/token revocation (#30) #37

Merged
whitlocktech merged 1 commits from bugfix/session-revocation-30 into main 2026-07-05 02:08:17 +00:00
Member

Closes #30.

Problem

Web sessions are stateless JWTs with no server-side store. The revocation hooks in session.service.js (revokeSession, invalidateSession, invalidateAllUserSessions) were stubs that only logged and returned true. Consequences:

  • Web logout was client-side only. logout cleared the caller's cookie but did nothing to the token itself — a copied cookie (proxy log, shared machine, XSS-exfiltrated value) stayed fully valid until the JWT's natural expiry (JWT_EXPIRES_IN, default 1 day).
  • Password change did not invalidate old sessions. users.update rehashed the password but never invalidated anything, so a stolen token kept working for up to a day even after the victim rotated their password.

requireAuth already re-reads the user row each request (so deleted/demoted users lose access promptly), but an intact user with a stolen still-valid token had no containment path. The mobile bearer flow already solved this with opaque, DB-stored, revocable refresh tokens; this brings the web/cookie flow to parity.

Approach — two-layer revocation

Both layers are enforced in requireAuth, which already loads the fresh user row on every authenticated request (so the per-user check is free, and the denylist adds one indexed PK lookup).

Layer Mechanism Covers
Per-session denylist new revoked_sessions table keyed on the JWT jti (already minted per session), self-pruning by the token's own exp precise "log out this browser"
Per-user cutoff new users.tokens_valid_after column; any token whose iat is at/before the cutoff is rejected "change password logs everyone out" / log-out-everywhere

The jti-vs-cutoff comparison is inclusive (iat <= cutoff): both values are second-granular, so a token minted in the same wall-clock second as the change must still be revoked (otherwise it would survive its full lifetime through that 1s alignment). The only cost is that a re-login within the same second as the change is rejected until the next second — a self-healing blip, far preferable to leaving a stale token valid.

Changes

  • server/db/schema.sql — new revoked_sessions table (jti PK, user_id FK, expires_at, revoked_at) and users.tokens_valid_after column, added to both the CREATE and the idempotent migration block (applied by ensureSchema() on boot).
  • server/src/model/revokedSessions/ (new) — revokedSessions.db.js / .model.js, mirroring the mobileSessions db/model split: revoke (idempotent INSERT IGNORE), isRevoked (unexpired only), pruneExpired.
  • session.service.js — sessions now carry expiresAt (JWT exp) so logout can set a self-pruning denylist row; the three revocation functions now delegate to the stores; added isSessionRevoked.
  • session.middleware.jsrequireAuth rejects a session whose jti is denylisted or whose iat is at/before tokens_valid_after.
  • auth.routes.js / auth.controller.js/logout gains best-effort attachSession so the controller can revoke this session's jti and log auth.logout; stays a no-op for anonymous callers, and never fails the logout on a revocation hiccup.
  • users.model.js / users.db.jsupdate bumps tokens_valid_after whenever the password hash is rotated; new invalidateSessions / bumpTokensValidAfter helpers.
  • server.js — prunes expired denylist rows on boot (best-effort, never blocks startup).

Verification

End-to-end against the local dev DB (server + MariaDB):

  • Logout revokes a stolen cookie — captured cookie value returns 200 from /me before logout, 401 after.
  • Password change invalidates existing sessions — an existing session returns 200, then 401 after PUT /admin/users/:id with a new password; tokens_valid_after is set; re-login with the new password succeeds.

Full server test suite green (96 passing), including new coverage for the denylist delegation, the missing-jti/missing-userId guards, and the expiresAt claim.

Notes / scope

  • attachSession stays DB-free (best-effort), so public routes that merely vary on auth could still show a revoked cookie as "logged in" — but no protected action is reachable without requireAuth, which enforces revocation.
  • JWT_EXPIRES_IN default left at 1d; the interim "shorten it" mitigation is now moot since sessions are revocable.

🤖 Generated with Claude Code

https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV

Closes #30. ## Problem Web sessions are stateless JWTs with no server-side store. The revocation hooks in `session.service.js` (`revokeSession`, `invalidateSession`, `invalidateAllUserSessions`) were stubs that only logged and returned `true`. Consequences: - **Web logout was client-side only.** `logout` cleared the caller's cookie but did nothing to the token itself — a copied cookie (proxy log, shared machine, XSS-exfiltrated value) stayed fully valid until the JWT's natural expiry (`JWT_EXPIRES_IN`, default 1 day). - **Password change did not invalidate old sessions.** `users.update` rehashed the password but never invalidated anything, so a stolen token kept working for up to a day even after the victim rotated their password. `requireAuth` already re-reads the user row each request (so deleted/demoted users lose access promptly), but an intact user with a stolen still-valid token had no containment path. The mobile bearer flow already solved this with opaque, DB-stored, revocable refresh tokens; this brings the web/cookie flow to parity. ## Approach — two-layer revocation Both layers are enforced in `requireAuth`, which already loads the fresh user row on every authenticated request (so the per-user check is free, and the denylist adds one indexed PK lookup). | Layer | Mechanism | Covers | |---|---|---| | **Per-session denylist** | new `revoked_sessions` table keyed on the JWT `jti` (already minted per session), self-pruning by the token's own `exp` | precise "log out this browser" | | **Per-user cutoff** | new `users.tokens_valid_after` column; any token whose `iat` is at/before the cutoff is rejected | "change password logs everyone out" / log-out-everywhere | The `jti`-vs-cutoff comparison is **inclusive** (`iat <= cutoff`): both values are second-granular, so a token minted in the same wall-clock second as the change must still be revoked (otherwise it would survive its full lifetime through that 1s alignment). The only cost is that a re-login within the same second as the change is rejected until the next second — a self-healing blip, far preferable to leaving a stale token valid. ## Changes - **`server/db/schema.sql`** — new `revoked_sessions` table (jti PK, user_id FK, `expires_at`, `revoked_at`) and `users.tokens_valid_after` column, added to both the `CREATE` and the idempotent migration block (applied by `ensureSchema()` on boot). - **`server/src/model/revokedSessions/`** (new) — `revokedSessions.db.js` / `.model.js`, mirroring the `mobileSessions` db/model split: `revoke` (idempotent `INSERT IGNORE`), `isRevoked` (unexpired only), `pruneExpired`. - **`session.service.js`** — sessions now carry `expiresAt` (JWT `exp`) so logout can set a self-pruning denylist row; the three revocation functions now delegate to the stores; added `isSessionRevoked`. - **`session.middleware.js`** — `requireAuth` rejects a session whose `jti` is denylisted or whose `iat` is at/before `tokens_valid_after`. - **`auth.routes.js` / `auth.controller.js`** — `/logout` gains best-effort `attachSession` so the controller can revoke this session's `jti` and log `auth.logout`; stays a no-op for anonymous callers, and never fails the logout on a revocation hiccup. - **`users.model.js` / `users.db.js`** — `update` bumps `tokens_valid_after` whenever the password hash is rotated; new `invalidateSessions` / `bumpTokensValidAfter` helpers. - **`server.js`** — prunes expired denylist rows on boot (best-effort, never blocks startup). ## Verification End-to-end against the local dev DB (server + MariaDB): - **Logout revokes a stolen cookie** — captured cookie value returns `200` from `/me` before logout, `401` after. - **Password change invalidates existing sessions** — an existing session returns `200`, then `401` after `PUT /admin/users/:id` with a new password; `tokens_valid_after` is set; re-login with the new password succeeds. Full server test suite green (**96 passing**), including new coverage for the denylist delegation, the missing-jti/missing-userId guards, and the `expiresAt` claim. ## Notes / scope - `attachSession` stays DB-free (best-effort), so public routes that merely *vary* on auth could still show a revoked cookie as "logged in" — but no protected action is reachable without `requireAuth`, which enforces revocation. - `JWT_EXPIRES_IN` default left at `1d`; the interim "shorten it" mitigation is now moot since sessions are revocable. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
wtclaude added 1 commit 2026-07-05 02:07:36 +00:00
Web sessions were stateless JWTs with no server-side store: the revocation
hooks in session.service were stubs that only logged. As a result web logout
was client-side only (a copied cookie stayed valid until natural JWT expiry)
and a password change never invalidated existing sessions. The mobile bearer
flow already had revocable, DB-stored tokens; this brings the web/cookie flow
to parity.

Two-layer revocation, both enforced in requireAuth (which already loads the
fresh user row each request):

- Per-session denylist: new `revoked_sessions` table keyed on the JWT `jti`
  (already minted per session). A single logout adds this session's jti;
  rows self-expire at the token's own exp and are pruned on boot. New model
  `revokedSessions` mirrors the `mobileSessions` db/model split.
- Per-user cutoff: new `users.tokens_valid_after` column. A password change
  (and the new `invalidateSessions` helper) bumps it to NOW(); any token whose
  iat is at or before the cutoff is rejected. The comparison is inclusive so a
  token minted in the same wall-clock second as the change is still revoked.

Wiring:
- session.service: revokeSession / invalidateSession / invalidateAllUserSessions
  now delegate to the stores; sessions carry `expiresAt` (JWT exp) so logout can
  set a self-pruning denylist row.
- /logout gains best-effort attachSession so the controller can revoke this
  session's jti and log auth.logout; stays a no-op for anonymous callers.
- users.model.update bumps the cutoff whenever the password hash is rotated.
- schema.sql: revoked_sessions table + tokens_valid_after column, added to the
  CREATE and to the idempotent migration block (ensureSchema on boot).

Verified end-to-end against the local dev DB: a captured cookie is rejected
after logout, and an existing session is rejected after a password change while
re-login with the new password succeeds. Full server test suite green (96).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
whitlocktech approved these changes 2026-07-05 02:08:09 +00:00
whitlocktech merged commit e8a54d9ff7 into main 2026-07-05 02:08:17 +00:00
whitlocktech deleted branch bugfix/session-revocation-30 2026-07-05 02:08:18 +00:00
Sign in to join this conversation.
No description provided.