diff --git a/apps/web/src/lib/auth-cookie.test.ts b/apps/web/src/lib/auth-cookie.test.ts new file mode 100644 index 0000000..71d7be0 --- /dev/null +++ b/apps/web/src/lib/auth-cookie.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { + signSession, + verifySession, + COOKIE_NAME, + DEFAULT_TTL_SECONDS, + type SessionPayload, +} from "./auth-cookie"; + +const SECRET = "test-secret-not-used-anywhere-real"; +const NOW = 1_700_000_000; // 2023-11-14 — fixed clock for determinism + +beforeAll(() => { + process.env.AUTH_SECRET = SECRET; + process.env.OPERATOR_TOKEN_VERSION = "1"; +}); + +const validPayload = (): SessionPayload => ({ + userId: "11111111-1111-1111-1111-111111111111", + role: "admin", + iat: NOW, + exp: NOW + DEFAULT_TTL_SECONDS, + v: 1, +}); + +describe("auth-cookie", () => { + it("signSession + verifySession round-trips a valid payload", async () => { + const cookie = await signSession(validPayload(), SECRET); + const verified = await verifySession(cookie, SECRET, NOW); + expect(verified).toEqual(validPayload()); + }); + + it("rejects when the payload portion has been tampered with", async () => { + const cookie = await signSession(validPayload(), SECRET); + // Flip the role to admin → user in the payload, keep the same signature. + const [, sig] = cookie.split("."); + const tampered = btoa(JSON.stringify({ ...validPayload(), role: "user" })) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, "") + "." + sig; + expect(await verifySession(tampered, SECRET, NOW)).toBeNull(); + }); + + it("rejects when the signature has been tampered with", async () => { + const cookie = await signSession(validPayload(), SECRET); + const [payload] = cookie.split("."); + const tampered = payload + ".AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + expect(await verifySession(tampered, SECRET, NOW)).toBeNull(); + }); + + it("rejects when verified with a different secret", async () => { + const cookie = await signSession(validPayload(), SECRET); + expect(await verifySession(cookie, "different-secret", NOW)).toBeNull(); + }); + + it("rejects an expired cookie (exp <= now)", async () => { + const expired = { ...validPayload(), exp: NOW - 1 }; + const cookie = await signSession(expired, SECRET); + expect(await verifySession(cookie, SECRET, NOW)).toBeNull(); + }); + + it("rejects a cookie issued in the future beyond clock-skew tolerance", async () => { + const future = { ...validPayload(), iat: NOW + 120 }; + const cookie = await signSession(future, SECRET); + expect(await verifySession(cookie, SECRET, NOW)).toBeNull(); + }); + + it("accepts a cookie issued slightly in the future (within 60s skew)", async () => { + const future = { ...validPayload(), iat: NOW + 30 }; + const cookie = await signSession(future, SECRET); + expect(await verifySession(cookie, SECRET, NOW)).not.toBeNull(); + }); + + it("rejects a cookie whose v is stale (token-version bumped)", async () => { + const cookie = await signSession({ ...validPayload(), v: 1 }, SECRET); + process.env.OPERATOR_TOKEN_VERSION = "2"; + expect(await verifySession(cookie, SECRET, NOW)).toBeNull(); + process.env.OPERATOR_TOKEN_VERSION = "1"; + }); + + it("rejects a cookie with an unknown role string", async () => { + const cookie = await signSession( + { ...validPayload(), role: "superadmin" as never }, + SECRET, + ); + expect(await verifySession(cookie, SECRET, NOW)).toBeNull(); + }); + + it("rejects a cookie that doesn't have a '.' separator", async () => { + expect(await verifySession("not-a-cookie", SECRET, NOW)).toBeNull(); + expect(await verifySession("", SECRET, NOW)).toBeNull(); + }); + + it("exposes COOKIE_NAME as 'session'", () => { + expect(COOKIE_NAME).toBe("session"); + }); +}); diff --git a/apps/web/src/lib/auth-cookie.ts b/apps/web/src/lib/auth-cookie.ts new file mode 100644 index 0000000..c3928f9 --- /dev/null +++ b/apps/web/src/lib/auth-cookie.ts @@ -0,0 +1,119 @@ +/** + * Edge-runtime-safe HMAC-signed session cookie. Runs in middleware + * and Server Actions. NO database, NO bcrypt, NO Node-only APIs — + * pure Web Crypto so it survives Edge runtime. + */ + +export const COOKIE_NAME = "session"; +export const DEFAULT_TTL_SECONDS = 30 * 86400; // 30 days +export const CLOCK_SKEW_SECONDS = 60; + +export type Role = "admin" | "user"; + +export interface SessionPayload { + userId: string; + role: Role; + iat: number; + exp: number; + v: number; +} + +function isValidPayload(x: unknown): x is SessionPayload { + if (typeof x !== "object" || x === null) return false; + const o = x as Record; + return ( + typeof o.userId === "string" && + (o.role === "admin" || o.role === "user") && + typeof o.iat === "number" && + typeof o.exp === "number" && + typeof o.v === "number" + ); +} + +function b64urlEncode(bytes: Uint8Array): string { + let s = ""; + for (const b of bytes) s += String.fromCharCode(b); + return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function b64urlDecode(str: string): Uint8Array { + const pad = str.length % 4 === 0 ? "" : "=".repeat(4 - (str.length % 4)); + const s = atob(str.replace(/-/g, "+").replace(/_/g, "/") + pad); + const out = new Uint8Array(s.length); + for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i); + return out; +} + +async function importKey(secret: string): Promise { + return crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign", "verify"], + ); +} + +/** Constant-time compare on byte arrays. Returns true iff equal. */ +function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!; + return diff === 0; +} + +export async function signSession( + payload: SessionPayload, + secret: string, +): Promise { + const json = JSON.stringify(payload); + const payloadEnc = b64urlEncode(new TextEncoder().encode(json)); + const key = await importKey(secret); + const sigBytes = new Uint8Array( + await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payloadEnc)), + ); + return `${payloadEnc}.${b64urlEncode(sigBytes)}`; +} + +export async function verifySession( + cookie: string, + secret: string, + now: number = Math.floor(Date.now() / 1000), +): Promise { + if (!cookie || typeof cookie !== "string") return null; + const dot = cookie.indexOf("."); + if (dot <= 0 || dot === cookie.length - 1) return null; + const payloadEnc = cookie.slice(0, dot); + const sigEnc = cookie.slice(dot + 1); + + let sigBytes: Uint8Array; + try { + sigBytes = b64urlDecode(sigEnc); + } catch { + return null; + } + + const key = await importKey(secret); + const expected = new Uint8Array( + await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payloadEnc)), + ); + if (!timingSafeEqual(sigBytes, expected)) return null; + + let json: string; + let payload: unknown; + try { + json = new TextDecoder().decode(b64urlDecode(payloadEnc)); + payload = JSON.parse(json); + } catch { + return null; + } + if (!isValidPayload(payload)) return null; + + if (payload.exp <= now) return null; + if (payload.iat > now + CLOCK_SKEW_SECONDS) return null; + + const expectedV = Number(process.env.OPERATOR_TOKEN_VERSION ?? "1"); + if (payload.v !== expectedV) return null; + + return payload; +}