Implement web session/token revocation (#30)

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
This commit is contained in:
2026-07-04 21:06:50 -05:00
parent 1cfb79f5ae
commit 933206a1b8
11 changed files with 274 additions and 20 deletions

View File

@@ -15,6 +15,18 @@ const sessionService = require('./session.service')
const users = require('../model/users/users.model')
const log = require('../utils/logger')('session')
// True if this session was issued at or before the user's tokens_valid_after
// cutoff (i.e. revoked by a password change / log-out-everywhere). Both the JWT
// iat and the cutoff are second-granular, so the comparison is inclusive: a token
// minted in the same second as the bump 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, and far preferable to leaving a stale token valid.
function isBeforeCutoff(session, tokensValidAfter) {
if (!tokensValidAfter || session.createdAt == null) return false
return session.createdAt <= new Date(tokensValidAfter).getTime()
}
// Best-effort: if the request carries a valid session token, attach the decoded
// session (no DB hit), its auth method, and request metadata. Never rejects —
// anonymous requests simply pass through with req.session undefined.
@@ -38,6 +50,18 @@ async function requireAuth(req, res, next) {
try {
const user = await users.getById(session.userId)
if (!user) return res.status(401).json({ message: 'Unauthorized' }) // deleted since token issued
// Revocation, enforced here (not in stateless token verification):
// 1. per-user cutoff — password change / "log out everywhere" bumps
// tokens_valid_after; any token issued before it is dead.
// 2. per-session denylist — a single logout adds this jti to revoked_sessions.
if (isBeforeCutoff(session, user.tokens_valid_after)) {
return res.status(401).json({ message: 'Unauthorized' })
}
if (await sessionService.isSessionRevoked(session.sessionId)) {
return res.status(401).json({ message: 'Unauthorized' })
}
req.user = user
req.session = session
req.authMethod = session.authMethod