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
182 lines
7.4 KiB
JavaScript
182 lines
7.4 KiB
JavaScript
// Set before requiring the auth layer (token.js reads JWT_SECRET at load) and
|
|
// db.js (the users model, pulled in via the utils/auth facade, builds the pool
|
|
// at load). Pointing the DB at a closed port stops idle connections from keeping
|
|
// this process alive — none of these tests touch the database.
|
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
|
|
process.env.DB_HOST = '127.0.0.1'
|
|
process.env.DB_PORT = '59999'
|
|
|
|
const { test, after } = require('node:test')
|
|
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())
|
|
|
|
const USER = { id: 7, username: 'alice', role: 'admin' }
|
|
|
|
// Build a request double carrying a token, either as a cookie or a Bearer header.
|
|
function reqWithCookie(token) {
|
|
return { cookies: { [authFacade.COOKIE_NAME]: token }, headers: {} }
|
|
}
|
|
function reqWithBearer(token) {
|
|
return { cookies: {}, headers: { authorization: `Bearer ${token}` } }
|
|
}
|
|
|
|
test('createSession → validateSession round-trips a Session object', () => {
|
|
const { token, session } = sessionService.createSession(USER, 'local')
|
|
assert.equal(typeof token, 'string')
|
|
|
|
// The returned session object carries the canonical shape.
|
|
assert.equal(session.userId, USER.id)
|
|
assert.equal(session.username, USER.username)
|
|
assert.equal(session.role, USER.role)
|
|
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))
|
|
assert.ok(validated)
|
|
assert.equal(validated.userId, USER.id)
|
|
assert.equal(validated.username, USER.username)
|
|
assert.equal(validated.role, USER.role)
|
|
assert.equal(validated.authMethod, 'local')
|
|
assert.equal(validated.sessionId, session.sessionId)
|
|
})
|
|
|
|
test('validateSession accepts a Bearer token as well as a cookie', () => {
|
|
const { token } = sessionService.createSession(USER, 'mobile')
|
|
const validated = sessionService.validateSession(reqWithBearer(token))
|
|
assert.ok(validated)
|
|
assert.equal(validated.userId, USER.id)
|
|
assert.equal(validated.authMethod, 'mobile')
|
|
})
|
|
|
|
test('authMethod defaults to local when an unknown method is passed', () => {
|
|
const { session } = sessionService.createSession(USER, 'bogus')
|
|
assert.equal(session.authMethod, 'local')
|
|
})
|
|
|
|
test('a partial (TOTP challenge) token is NOT a valid session', () => {
|
|
const challenge = sessionService.createPartialSession(USER)
|
|
assert.equal(typeof challenge, 'string')
|
|
// Stage-tagged tokens must never validate as a full session.
|
|
assert.equal(sessionService.validateSession(reqWithCookie(challenge)), null)
|
|
assert.equal(sessionService.decodeIdentity(challenge), null)
|
|
})
|
|
|
|
test('upgradeSessionAfterTotp accepts a challenge and rejects a session token', () => {
|
|
const challenge = sessionService.createPartialSession(USER)
|
|
const decoded = sessionService.upgradeSessionAfterTotp(challenge)
|
|
assert.ok(decoded)
|
|
assert.equal(decoded.id, USER.id)
|
|
assert.equal(decoded.stage, 'totp')
|
|
|
|
// A normal session token is not a TOTP challenge — must be rejected here.
|
|
const { token } = sessionService.createSession(USER, 'local')
|
|
assert.equal(sessionService.upgradeSessionAfterTotp(token), null)
|
|
})
|
|
|
|
test('validateSession / decodeIdentity return null for missing or garbage input', () => {
|
|
assert.equal(sessionService.validateSession({ cookies: {}, headers: {} }), null)
|
|
assert.equal(sessionService.decodeIdentity(null), null)
|
|
assert.equal(sessionService.decodeIdentity('not-a-jwt'), null)
|
|
})
|
|
|
|
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', () => {
|
|
const meta = sessionService.sessionMeta({ ip: '203.0.113.5', headers: { 'user-agent': 'jest' } })
|
|
assert.equal(meta.ip, '203.0.113.5')
|
|
assert.equal(meta.userAgent, 'jest')
|
|
assert.equal(typeof meta.deviceHash, 'string')
|
|
assert.ok(meta.deviceHash.length > 0)
|
|
})
|
|
|
|
test('backward-compat: utils/auth facade still exports the original API', () => {
|
|
for (const name of [
|
|
'isLoggedIn',
|
|
'requireRole',
|
|
'signToken',
|
|
'verifyToken',
|
|
'signTotpChallenge',
|
|
'verifyTotpChallenge',
|
|
'setAuthCookie',
|
|
'clearAuthCookie',
|
|
'getUserFromRequest',
|
|
]) {
|
|
assert.equal(typeof authFacade[name], 'function', `${name} is exported as a function`)
|
|
}
|
|
assert.equal(typeof authFacade.COOKIE_NAME, 'string')
|
|
|
|
// getUserFromRequest still returns the historical { id, username, role } shape.
|
|
const { token } = sessionService.createSession(USER, 'local')
|
|
const decoded = authFacade.getUserFromRequest(reqWithCookie(token))
|
|
assert.deepEqual(decoded, { id: USER.id, username: USER.username, role: USER.role })
|
|
assert.equal(authFacade.getUserFromRequest({ cookies: {}, headers: {} }), null)
|
|
})
|