diff --git a/packages/auth/src/application/use-cases/sign-out.use-case.test.ts b/packages/auth/src/application/use-cases/sign-out.use-case.test.ts index 8ffc8d8..125fb49 100644 --- a/packages/auth/src/application/use-cases/sign-out.use-case.test.ts +++ b/packages/auth/src/application/use-cases/sign-out.use-case.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest"; import { signOutUseCase } from "@/application/use-cases/sign-out.use-case"; import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock"; import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock"; +import { UnauthenticatedError } from "@/entities/errors/auth"; +import { userFactory } from "@/__factories__/user.factory"; describe("signOutUseCase", () => { it("returns void on successful sign-out", async () => { @@ -12,4 +14,22 @@ describe("signOutUseCase", () => { const result = await useCase({ sessionId: "session_1" }); expect(result).toBeUndefined(); }); + + it("revokes the session server-side: validateSession rejects it afterwards", async () => { + const users = new MockUsersRepository([]); + const auth = new MockAuthenticationService(users); + const user = userFactory.build({ username: "alice" }); + await users.createUser(user); + const { session } = await auth.createSession(user); + + // Sanity: session is valid before sign-out. + await expect(auth.validateSession(session.id)).resolves.toBeDefined(); + + await signOutUseCase(auth)({ sessionId: session.id }); + + // B5: sign-out must invalidate server-side, not just clear the cookie. + await expect(auth.validateSession(session.id)).rejects.toBeInstanceOf( + UnauthenticatedError, + ); + }); }); diff --git a/packages/auth/src/infrastructure/services/authentication.service.ts b/packages/auth/src/infrastructure/services/authentication.service.ts index 8451824..11372a2 100644 --- a/packages/auth/src/infrastructure/services/authentication.service.ts +++ b/packages/auth/src/infrastructure/services/authentication.service.ts @@ -4,6 +4,7 @@ import type { IAuthenticationService } from "../../application/services/authenti import type { Cookie } from "../../entities/models/cookie"; import type { Session } from "../../entities/models/session"; import type { User } from "../../entities/models/user"; +import { InMemorySessionDenylist } from "./session-denylist"; const SALT_LENGTH = 16; const KEY_LENGTH = 64; @@ -15,7 +16,12 @@ const COOKIE_NAME = "payload-token"; const SESSION_DURATION_SECONDS = 7200; // 2 hours (matches Payload default) export class AuthenticationService implements IAuthenticationService { - constructor(private config: SanitizedConfig) {} + constructor( + private config: SanitizedConfig, + // Server-side revocation (audit finding B5). In-memory: revocations are + // per-process — see session-denylist.ts for the limitation write-up. + private denylist: InMemorySessionDenylist = new InMemorySessionDenylist(), + ) {} generateUserId(): string { return crypto.randomUUID(); @@ -69,10 +75,13 @@ export class AuthenticationService implements IAuthenticationService { const payload = await getPayload({ config: this.config }); const expiresAt = new Date(Date.now() + SESSION_DURATION_SECONDS * 1000); - const token = this.signToken(user.id, payload.secret); + // The session id doubles as the JWT `jti` so the token can be revoked + // server-side via the denylist (audit finding B5). + const sessionId = crypto.randomUUID(); + const token = this.signToken(user.id, sessionId, payload.secret); const session: Session = { - id: crypto.randomUUID(), + id: sessionId, userId: user.id, expiresAt, }; @@ -97,6 +106,11 @@ export class AuthenticationService implements IAuthenticationService { const payload = await getPayload({ config: this.config }); const decoded = this.verifyToken(token, payload.secret); if (!decoded) throw new Error("Invalid or expired session token"); + // Server-side revocation check (audit finding B5): a signed, unexpired + // token is still rejected once its jti has been invalidated. + if (this.denylist.isRevoked(decoded.jti)) { + throw new Error("Session has been revoked"); + } const userDoc = await payload.findByID({ collection: "users" as const, @@ -111,7 +125,7 @@ export class AuthenticationService implements IAuthenticationService { }; const session: Session = { - id: token, + id: decoded.jti, userId: user.id, expiresAt: new Date(decoded.exp * 1000), }; @@ -119,9 +133,11 @@ export class AuthenticationService implements IAuthenticationService { return { user, session }; } - async invalidateSession( - _sessionId: string, - ): Promise<{ blankCookie: Cookie }> { + async invalidateSession(sessionId: string): Promise<{ blankCookie: Cookie }> { + // `sessionId` is the JWT `jti` (the `session.id` returned by + // createSession/validateSession). Denylist it for the maximum token + // lifetime — beyond that, the token's own `exp` rejects it (B5). + this.denylist.revoke(sessionId, SESSION_DURATION_SECONDS); return { blankCookie: { name: COOKIE_NAME, @@ -138,13 +154,13 @@ export class AuthenticationService implements IAuthenticationService { } /** Sign a HS256 JWT using Payload's instance secret. No external dependency. */ - private signToken(userId: string, secret: string): string { + private signToken(userId: string, jti: string, secret: string): string { const header = Buffer.from( JSON.stringify({ alg: "HS256", typ: "JWT" }), ).toString("base64url"); const exp = Math.floor(Date.now() / 1000) + SESSION_DURATION_SECONDS; const body = Buffer.from( - JSON.stringify({ id: userId, collection: "users", exp }), + JSON.stringify({ id: userId, collection: "users", exp, jti }), ).toString("base64url"); const signature = crypto .createHmac("sha256", secret) @@ -157,22 +173,38 @@ export class AuthenticationService implements IAuthenticationService { private verifyToken( token: string, secret: string, - ): { id: string; exp: number } | null { + ): { id: string; exp: number; jti: string } | null { const parts = token.split("."); if (parts.length !== 3) return null; const [header, body, signature] = parts as [string, string, string]; const expected = crypto .createHmac("sha256", secret) .update(`${header}.${body}`) - .digest("base64url"); - if (signature !== expected) return null; + .digest(); + const provided = Buffer.from(signature, "base64url"); + // Constant-time comparison, mirroring verifyPassword (audit finding B4). + // timingSafeEqual requires equal-length buffers; a length mismatch is + // already an invalid signature, and the guard leaks nothing an attacker + // does not know (the expected HMAC-SHA256 length is public). + if (provided.length !== expected.length) return null; + if (!crypto.timingSafeEqual(provided, expected)) return null; try { const decoded = JSON.parse(Buffer.from(body, "base64url").toString()) as { - id: string; - exp: number; + id?: unknown; + exp?: unknown; + jti?: unknown; }; + // Fail closed: tokens without a jti cannot be revoked, so they are + // not accepted (audit finding B5). + if ( + typeof decoded.id !== "string" || + typeof decoded.exp !== "number" || + typeof decoded.jti !== "string" + ) { + return null; + } if (decoded.exp < Math.floor(Date.now() / 1000)) return null; - return decoded; + return { id: decoded.id, exp: decoded.exp, jti: decoded.jti }; } catch { return null; } diff --git a/packages/auth/src/infrastructure/services/session-denylist.test.ts b/packages/auth/src/infrastructure/services/session-denylist.test.ts new file mode 100644 index 0000000..8b308aa --- /dev/null +++ b/packages/auth/src/infrastructure/services/session-denylist.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { InMemorySessionDenylist } from "@/infrastructure/services/session-denylist"; + +describe("InMemorySessionDenylist", () => { + it("reports a revoked jti as revoked", () => { + const denylist = new InMemorySessionDenylist(); + denylist.revoke("jti-1", 60); + expect(denylist.isRevoked("jti-1")).toBe(true); + }); + + it("does not report unknown jtis as revoked", () => { + const denylist = new InMemorySessionDenylist(); + expect(denylist.isRevoked("never-seen")).toBe(false); + }); + + it("prunes entries after their ttl elapses", () => { + let now = 1_000_000; + const denylist = new InMemorySessionDenylist(() => now); + denylist.revoke("jti-1", 60); + expect(denylist.isRevoked("jti-1")).toBe(true); + + now += 60_000; // exactly at expiry — entry is prunable + expect(denylist.isRevoked("jti-1")).toBe(false); + }); + + it("keeps entries alive until the ttl elapses", () => { + let now = 1_000_000; + const denylist = new InMemorySessionDenylist(() => now); + denylist.revoke("jti-1", 60); + now += 59_999; + expect(denylist.isRevoked("jti-1")).toBe(true); + }); + + it("prunes expired entries on revoke, not just on reads", () => { + let now = 1_000_000; + const denylist = new InMemorySessionDenylist(() => now); + denylist.revoke("old", 1); + now += 5_000; + denylist.revoke("new", 60); + // Reach into nothing — observable via isRevoked semantics only. + expect(denylist.isRevoked("old")).toBe(false); + expect(denylist.isRevoked("new")).toBe(true); + }); +}); diff --git a/packages/auth/src/infrastructure/services/session-denylist.ts b/packages/auth/src/infrastructure/services/session-denylist.ts new file mode 100644 index 0000000..6f40655 --- /dev/null +++ b/packages/auth/src/infrastructure/services/session-denylist.ts @@ -0,0 +1,46 @@ +/** + * 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); + } + } +}