syntaxai/tdd.md · main · src / b32_session.ts

b32_session.ts 82 lines · 3088 bytes raw
// 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}`;