import crypto from "node:crypto"; import { getPayload, type SanitizedConfig } from "payload"; import type { IAuthenticationService } from "../../application/services/authentication.service.interface"; 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; const ITERATIONS = 100_000; const DIGEST = "sha512"; const SEPARATOR = ":"; const COOKIE_NAME = "payload-token"; const SESSION_DURATION_SECONDS = 7200; // 2 hours (matches Payload default) export class AuthenticationService implements IAuthenticationService { 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(); } async hashPassword(password: string): Promise { const salt = crypto.randomBytes(SALT_LENGTH).toString("hex"); const hash = await new Promise((resolve, reject) => { crypto.pbkdf2( password, salt, ITERATIONS, KEY_LENGTH, DIGEST, (err, derivedKey) => { if (err) reject(err); else resolve(derivedKey.toString("hex")); }, ); }); return `${salt}${SEPARATOR}${hash}`; } async verifyPassword(storedHash: string, password: string): Promise { const parts = storedHash.split(SEPARATOR); if (parts.length !== 2) return false; const salt = parts[0]!; const expectedHash = parts[1]!; const actualHash = await new Promise((resolve, reject) => { crypto.pbkdf2( password, salt, ITERATIONS, KEY_LENGTH, DIGEST, (err, derivedKey) => { if (err) reject(err); else resolve(derivedKey.toString("hex")); }, ); }); return crypto.timingSafeEqual( Buffer.from(expectedHash, "hex"), Buffer.from(actualHash, "hex"), ); } async createSession( user: User, ): Promise<{ session: Session; cookie: Cookie }> { const payload = await getPayload({ config: this.config }); const expiresAt = new Date(Date.now() + SESSION_DURATION_SECONDS * 1000); // 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: sessionId, userId: user.id, expiresAt, }; const cookie: Cookie = { name: COOKIE_NAME, value: token, attributes: { httpOnly: true, secure: process.env.NODE_ENV === "production", path: "/", sameSite: "lax", maxAge: SESSION_DURATION_SECONDS, }, }; return { session, cookie }; } async validateSession( token: string, ): Promise<{ user: User; session: Session }> { 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, id: decoded.id, overrideAccess: true, }); const user: User = { id: userDoc.id as string, username: (userDoc as Record).username as string, passwordHash: (userDoc as Record).passwordHash as string, }; const session: Session = { id: decoded.jti, userId: user.id, expiresAt: new Date(decoded.exp * 1000), }; return { user, session }; } 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, value: "", attributes: { httpOnly: true, secure: process.env.NODE_ENV === "production", path: "/", sameSite: "lax", maxAge: 0, }, }, }; } /** Sign a HS256 JWT using Payload's instance secret. No external dependency. */ 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, jti }), ).toString("base64url"); const signature = crypto .createHmac("sha256", secret) .update(`${header}.${body}`) .digest("base64url"); return `${header}.${body}.${signature}`; } /** Verify and decode a HS256 JWT. Returns null on invalid/expired token. */ private verifyToken( token: string, secret: string, ): { 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(); 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?: 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 { id: decoded.id, exp: decoded.exp, jti: decoded.jti }; } catch { return null; } } }