Implement web session/token revocation (#30) #37
Reference in New Issue
Block a user
No description provided.
Delete Branch "bugfix/session-revocation-30"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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 returnedtrue. Consequences:logoutcleared 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).users.updaterehashed the password but never invalidated anything, so a stolen token kept working for up to a day even after the victim rotated their password.requireAuthalready 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).revoked_sessionstable keyed on the JWTjti(already minted per session), self-pruning by the token's ownexpusers.tokens_valid_aftercolumn; any token whoseiatis at/before the cutoff is rejectedThe
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— newrevoked_sessionstable (jti PK, user_id FK,expires_at,revoked_at) andusers.tokens_valid_aftercolumn, added to both theCREATEand the idempotent migration block (applied byensureSchema()on boot).server/src/model/revokedSessions/(new) —revokedSessions.db.js/.model.js, mirroring themobileSessionsdb/model split:revoke(idempotentINSERT IGNORE),isRevoked(unexpired only),pruneExpired.session.service.js— sessions now carryexpiresAt(JWTexp) so logout can set a self-pruning denylist row; the three revocation functions now delegate to the stores; addedisSessionRevoked.session.middleware.js—requireAuthrejects a session whosejtiis denylisted or whoseiatis at/beforetokens_valid_after.auth.routes.js/auth.controller.js—/logoutgains best-effortattachSessionso the controller can revoke this session'sjtiand logauth.logout; stays a no-op for anonymous callers, and never fails the logout on a revocation hiccup.users.model.js/users.db.js—updatebumpstokens_valid_afterwhenever the password hash is rotated; newinvalidateSessions/bumpTokensValidAfterhelpers.server.js— prunes expired denylist rows on boot (best-effort, never blocks startup).Verification
End-to-end against the local dev DB (server + MariaDB):
200from/mebefore logout,401after.200, then401afterPUT /admin/users/:idwith a new password;tokens_valid_afteris 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
expiresAtclaim.Notes / scope
attachSessionstays 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 withoutrequireAuth, which enforces revocation.JWT_EXPIRES_INdefault left at1d; the interim "shorten it" mitigation is now moot since sessions are revocable.🤖 Generated with Claude Code
https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV