// c32 — logic: session signing/verification + cookie helpers. Pure // HMAC over the session payload, no I/O. Handlers (c21) pull a viewer // off the request via getViewer(), and the OAuth callback issues a // session cookie via sessionCookieHeader + signSession. // 30 days. Long enough for everyday use, short enough that a leaked // cookie doesn't grant indefinite access. export const SESSION_TTL_SEC = 30 * 24 * 60 * 60; const SESSION_COOKIE = "tdd_session"; const sessionSecret = (): string => process.env.SESSION_SECRET ?? process.env.WEBHOOK_SECRET ?? ""; export const randomHex = (bytes: number): string => Array.from(crypto.getRandomValues(new Uint8Array(bytes))) .map((b) => b.toString(16).padStart(2, "0")) .join(""); export const parseCookies = (header: string | null): Record => { const out: Record = {}; if (!header) return out; for (const part of header.split(";")) { const idx = part.indexOf("="); if (idx === -1) continue; const name = part.slice(0, idx).trim(); const value = part.slice(idx + 1).trim(); if (name) out[name] = decodeURIComponent(value); } return out; }; export const timingSafeEqual = (a: string, b: string): boolean => { if (a.length !== b.length) return false; let r = 0; for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i); return r === 0; }; export const hmacSha256Hex = async (secret: string, body: string): Promise => { const key = await crypto.subtle.importKey( "raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body)); return Array.from(new Uint8Array(sig)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); }; export const signSession = async (username: string): Promise => { const exp = Math.floor(Date.now() / 1000) + SESSION_TTL_SEC; const payload = `${username}.${exp}`; const sig = await hmacSha256Hex(sessionSecret(), payload); return `${payload}.${sig}`; }; export const verifySession = async (cookie: string): Promise => { const parts = cookie.split("."); if (parts.length !== 3) return null; const [username, expStr, providedSig] = parts; if (!username || !expStr || !providedSig) return null; const exp = Number(expStr); if (!Number.isFinite(exp) || exp < Math.floor(Date.now() / 1000)) return null; const expectedSig = await hmacSha256Hex(sessionSecret(), `${username}.${expStr}`); if (!timingSafeEqual(providedSig, expectedSig)) return null; return username; }; export const getViewer = async (req: Request): Promise => { if (!sessionSecret()) return null; const cookies = parseCookies(req.headers.get("cookie")); const raw = cookies[SESSION_COOKIE]; if (!raw) return null; return verifySession(raw); }; export const sessionCookieHeader = (value: string, maxAge: number): string => `${SESSION_COOKIE}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`;