/** * In-memory JWT `jti` denylist backing server-side session revocation * (audit finding B5). * * `AuthenticationService.createSession` mints a session id and embeds it in * the JWT as `jti`; `invalidateSession(jti)` records it here and * `validateSession` rejects any token whose `jti` is denylisted. Entries * expire with the token they revoke (max session lifetime), so the map is * self-pruning and cannot grow past the number of sign-outs per lifetime * window. * * SINGLE-PROCESS LIMITATION: this denylist lives in process memory. It is * correct for a single server process (the template's deployment shape) but * revocations are NOT shared across processes/instances and do not survive * restarts — a restarted process accepts a signed, unexpired token again. * Multi-instance deployments must swap this for a shared store (Redis, DB) * behind the same two methods. */ export class InMemorySessionDenylist { /** jti -> epoch-ms after which the entry may be pruned. */ private readonly revoked = new Map(); constructor(private readonly clock: () => number = () => Date.now()) {} /** * Record a revoked `jti`. `ttlSeconds` should be the maximum remaining * token lifetime — after that, the token's own `exp` rejects it anyway. */ revoke(jti: string, ttlSeconds: number): void { this.prune(); this.revoked.set(jti, this.clock() + ttlSeconds * 1000); } isRevoked(jti: string): boolean { this.prune(); return this.revoked.has(jti); } /** Expiry-based pruning — runs on every access; the map stays small. */ private prune(): void { const now = this.clock(); for (const [jti, expiresAt] of this.revoked) { if (expiresAt <= now) this.revoked.delete(jti); } } }