import { createHmac, timingSafeEqual } from 'node:crypto'; export interface SessionPayload { uid: string; sv: number; /** Expiry, ms since epoch. */ exp: number; } export const SESSION_COOKIE = 'session'; export const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60; function sign(data: string, secret: string): string { return createHmac('sha256', secret).update(data).digest('base64url'); } export function createSessionToken(payload: Omit, secret: string, now = new Date(), ttlSeconds = SESSION_TTL_SECONDS): string { const full: SessionPayload = { ...payload, exp: now.getTime() + ttlSeconds * 1000 }; const data = Buffer.from(JSON.stringify(full)).toString('base64url'); return `${data}.${sign(data, secret)}`; } /** * Verifies only the token's signature and expiry. This does NOT prove the session is still * valid: callers MUST also check that `payload.uid` refers to a user that still exists, is * active, and that `user.sessionVersion === payload.sv` (the hooks do this). */ export function verifySessionToken(token: string, secret: string, now = new Date()): SessionPayload | null { const dot = token.lastIndexOf('.'); if (dot <= 0) return null; const data = token.slice(0, dot); const sig = token.slice(dot + 1); const expected = sign(data, secret); const a = Buffer.from(sig); const b = Buffer.from(expected); if (a.length !== b.length || !timingSafeEqual(a, b)) return null; try { const parsed = JSON.parse(Buffer.from(data, 'base64url').toString()) as Partial; if (typeof parsed.uid !== 'string' || typeof parsed.sv !== 'number' || typeof parsed.exp !== 'number') return null; if (parsed.exp <= now.getTime()) return null; return { uid: parsed.uid, sv: parsed.sv, exp: parsed.exp }; } catch { return null; } }