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

@@ -11,6 +11,8 @@ const assert = require('node:assert/strict')
const sessionService = require('../src/auth/session.service')
const authFacade = require('../src/utils/auth')
const revokedSessions = require('../src/model/revokedSessions/revokedSessions.model')
const usersModel = require('../src/model/users/users.model')
const db = require('../src/utils/db')
after(() => db.close())
@@ -36,6 +38,9 @@ test('createSession → validateSession round-trips a Session object', () => {
assert.equal(session.authMethod, 'local')
assert.ok(session.sessionId, 'sessionId (jti) is present')
assert.equal(typeof session.createdAt, 'number')
// exp is carried so logout can set a self-pruning denylist row expiry.
assert.equal(typeof session.expiresAt, 'number')
assert.ok(session.expiresAt > session.createdAt, 'expiresAt is after createdAt')
// Validating the same token off a request yields the same identity.
const validated = sessionService.validateSession(reqWithCookie(token))
@@ -86,10 +91,62 @@ test('validateSession / decodeIdentity return null for missing or garbage input'
assert.equal(sessionService.decodeIdentity('not-a-jwt'), null)
})
test('revoke / invalidate stubs report success without throwing', () => {
assert.equal(sessionService.revokeSession('sid-1'), true)
assert.equal(sessionService.invalidateSession('sid-1'), true)
assert.equal(sessionService.invalidateAllUserSessions(USER.id), true)
test('revokeSession denylists the jti with the token expiry', async () => {
// Stub the store (the DB is intentionally unreachable in these tests) and
// capture what the seam persists. sessionService holds the same module object,
// so overwriting the method here is what it calls.
const calls = []
const orig = revokedSessions.revoke
revokedSessions.revoke = async (args) => { calls.push(args); return 1 }
try {
const exp = Date.now() + 60_000
const ok = await sessionService.revokeSession('sid-1', { userId: USER.id, expiresAt: exp })
assert.equal(ok, true)
assert.equal(calls.length, 1)
assert.equal(calls[0].jti, 'sid-1')
assert.equal(calls[0].userId, USER.id)
assert.equal(calls[0].expiresAt, exp)
} finally {
revokedSessions.revoke = orig
}
})
test('revokeSession is a no-op (returns false) without a sessionId', async () => {
let called = false
const orig = revokedSessions.revoke
revokedSessions.revoke = async () => { called = true; return 1 }
try {
assert.equal(await sessionService.revokeSession(undefined), false)
assert.equal(called, false, 'nothing is persisted when there is no jti')
} finally {
revokedSessions.revoke = orig
}
})
test('isSessionRevoked delegates to the denylist (and short-circuits on null)', async () => {
const orig = revokedSessions.isRevoked
revokedSessions.isRevoked = async (jti) => jti === 'revoked-sid'
try {
assert.equal(await sessionService.isSessionRevoked('revoked-sid'), true)
assert.equal(await sessionService.isSessionRevoked('fresh-sid'), false)
assert.equal(await sessionService.isSessionRevoked(null), false)
} finally {
revokedSessions.isRevoked = orig
}
})
test('invalidateAllUserSessions bumps the user cutoff (and guards a missing id)', async () => {
const ids = []
const orig = usersModel.invalidateSessions
usersModel.invalidateSessions = async (id) => { ids.push(id); return undefined }
try {
assert.equal(await sessionService.invalidateAllUserSessions(USER.id), true)
assert.deepEqual(ids, [USER.id])
assert.equal(await sessionService.invalidateAllUserSessions(undefined), false)
assert.deepEqual(ids, [USER.id], 'no bump when userId is missing')
} finally {
usersModel.invalidateSessions = orig
}
})
test('sessionMeta derives ip / userAgent / deviceHash from the request', () => {