syntaxai/tdd.md · main · src / b32_session.ts
// 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<string, string> => {
const out: Record<string, string> = {};
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<string> => {
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<string> => {
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<string | null> => {
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<string | null> => {
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}`;