/** * Stateless admin session — a single password gates the whole CMS. * * On login we mint an HMAC-signed token (payload + signature) and store it in * an httpOnly cookie. Verification re-computes the HMAC and checks expiry. * Everything here uses the Web Crypto API only (no `node:crypto`) so the same * code runs in Edge middleware AND in Node route handlers. */ export const SESSION_COOKIE = 'sa_admin'; export const SESSION_MAX_AGE = 60 * 60 * 24 * 7; // 7 days, in seconds const enc = new TextEncoder(); const dec = new TextDecoder(); /** Signing secret. In production set ADMIN_SESSION_SECRET (or it derives from * ADMIN_PASSWORD). A loud, obviously-insecure default keeps dev frictionless. */ function getSecret(): string { return ( process.env.ADMIN_SESSION_SECRET || process.env.ADMIN_PASSWORD || 'dev-insecure-secret-change-me' ); } /** The single admin password. Falls back to "admin" in non-production only. */ function getPassword(): string | undefined { if (process.env.ADMIN_PASSWORD) return process.env.ADMIN_PASSWORD; if (process.env.NODE_ENV !== 'production') return 'admin'; return undefined; } function toB64url(bytes: ArrayBuffer | Uint8Array): string { const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); let bin = ''; for (let i = 0; i < arr.length; i++) bin += String.fromCharCode(arr[i]); return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } function fromB64url(str: string): Uint8Array { const b64 = str.replace(/-/g, '+').replace(/_/g, '/') + '=='.slice((str.length + 3) % 4); const bin = atob(b64); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } async function hmacKey(): Promise { return crypto.subtle.importKey( 'raw', enc.encode(getSecret()), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify'], ); } async function sha256Hex(input: string): Promise { const digest = await crypto.subtle.digest('SHA-256', enc.encode(input)); return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join(''); } /** Constant-time comparison of two equal-length hex strings. */ function timingSafeEqual(a: string, b: string): boolean { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); return diff === 0; } /** Mint a signed session token. */ export async function createSession(): Promise { const payload = { iat: Date.now(), exp: Date.now() + SESSION_MAX_AGE * 1000 }; const data = toB64url(enc.encode(JSON.stringify(payload))); const sig = await crypto.subtle.sign('HMAC', await hmacKey(), enc.encode(data)); return `${data}.${toB64url(sig)}`; } /** True when the token's signature is valid and it has not expired. */ export async function verifySession(token?: string | null): Promise { if (!token) return false; const dot = token.indexOf('.'); if (dot <= 0) return false; const data = token.slice(0, dot); const sig = token.slice(dot + 1); try { const valid = await crypto.subtle.verify( 'HMAC', await hmacKey(), fromB64url(sig), enc.encode(data), ); if (!valid) return false; const payload = JSON.parse(dec.decode(fromB64url(data))) as { exp?: number }; return typeof payload.exp === 'number' && payload.exp > Date.now(); } catch { return false; } } /** Check a submitted password against the configured one (constant-time). */ export async function verifyPassword(input: string): Promise { const expected = getPassword(); if (!expected) return false; // No password set in production => locked out. const [a, b] = await Promise.all([sha256Hex(input), sha256Hex(expected)]); return timingSafeEqual(a, b); } /** Whether an admin password is configured (used to warn in the UI). */ export function passwordConfigured(): boolean { return Boolean(getPassword()); }