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"; 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) {} 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); const token = this.signToken(user.id, payload.secret); const session: Session = { id: crypto.randomUUID(), 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"); 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: token, userId: user.id, expiresAt: new Date(decoded.exp * 1000), }; return { user, session }; } async invalidateSession( _sessionId: string, ): Promise<{ blankCookie: Cookie }> { 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, 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 }), ).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 } | 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; try { const decoded = JSON.parse(Buffer.from(body, "base64url").toString()) as { id: string; exp: number; }; if (decoded.exp < Math.floor(Date.now() / 1000)) return null; return decoded; } catch { return null; } } }