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:
40
server/src/model/revokedSessions/revokedSessions.db.js
Normal file
40
server/src/model/revokedSessions/revokedSessions.db.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// SQL for the revoked_sessions denylist. Rows are keyed on a session's JWT `jti`
|
||||
// and carry the token's own expiry so they can be pruned once the underlying JWT
|
||||
// would fail verification anyway. This is the web/cookie analogue of
|
||||
// mobile_refresh_tokens (opaque, DB-stored, revocable).
|
||||
|
||||
// Add a jti to the denylist. INSERT IGNORE makes a repeat logout of the same
|
||||
// session a harmless no-op (the PK already exists). Returns rows changed.
|
||||
async function add({ jti, userId = null, expiresAt }) {
|
||||
const res = await query(
|
||||
`INSERT IGNORE INTO revoked_sessions (jti, user_id, expires_at)
|
||||
VALUES (?, ?, ?)`,
|
||||
[jti, userId, new Date(expiresAt)],
|
||||
)
|
||||
return Number(res.affectedRows || 0)
|
||||
}
|
||||
|
||||
// True if this jti is on the denylist and not yet past its stored expiry. Past
|
||||
// expiry the token itself is already invalid, so a lingering row need not match.
|
||||
async function isRevoked(jti) {
|
||||
if (!jti) return false
|
||||
const rows = await query(
|
||||
'SELECT 1 FROM revoked_sessions WHERE jti = ? AND expires_at > NOW() LIMIT 1',
|
||||
[jti],
|
||||
)
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
// Housekeeping: drop rows whose token has already expired. Returns rows removed.
|
||||
async function pruneExpired() {
|
||||
const res = await query('DELETE FROM revoked_sessions WHERE expires_at < NOW()')
|
||||
return Number(res.affectedRows || 0)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
add,
|
||||
isRevoked,
|
||||
pruneExpired,
|
||||
}
|
||||
29
server/src/model/revokedSessions/revokedSessions.model.js
Normal file
29
server/src/model/revokedSessions/revokedSessions.model.js
Normal file
@@ -0,0 +1,29 @@
|
||||
// Web/cookie session denylist. Thin logic layer over revokedSessions.db — mirrors
|
||||
// the users/mobileSessions split (.db = SQL, .model = the API the rest of the app
|
||||
// calls). A "revoked session" is a single JWT jti added on logout; requireAuth
|
||||
// checks isRevoked on every authenticated request. Broad invalidation
|
||||
// ("everywhere" / password change) does NOT live here — it bumps
|
||||
// users.tokens_valid_after instead.
|
||||
|
||||
const db = require('./revokedSessions.db')
|
||||
|
||||
// Add a session's jti to the denylist (single-session logout). Idempotent.
|
||||
async function revoke({ jti, userId, expiresAt }) {
|
||||
return db.add({ jti, userId, expiresAt })
|
||||
}
|
||||
|
||||
// True if the given jti has been revoked (and its token hasn't expired yet).
|
||||
async function isRevoked(jti) {
|
||||
return db.isRevoked(jti)
|
||||
}
|
||||
|
||||
// Drop denylist rows whose token has already expired.
|
||||
async function pruneExpired() {
|
||||
return db.pruneExpired()
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
revoke,
|
||||
isRevoked,
|
||||
pruneExpired,
|
||||
}
|
||||
Reference in New Issue
Block a user