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 (AES-256-GCM)", () => { 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("uses a fresh IV per call so two encryptions of the same payload differ", async () => { // Repeating the IV under AES-GCM is catastrophic (it leaks the XOR // of plaintexts and the auth key). Lock in that signSession draws // a new nonce every time — the byte-for-byte cookies must not match // even when the inputs are identical. const a = await signSession(validPayload(), SECRET); const b = await signSession(validPayload(), SECRET); expect(a).not.toBe(b); // Both still decrypt correctly with the same secret. expect(await verifySession(a, SECRET, NOW)).toEqual(validPayload()); expect(await verifySession(b, SECRET, NOW)).toEqual(validPayload()); }); it("ciphertext does NOT leak the userId in plaintext (confidentiality)", async () => { const cookie = await signSession(validPayload(), SECRET); // The whole point of the GCM upgrade: someone with only the cookie // value should not be able to read the userId / role straight off // it the way they could with the old base64-encoded JSON. expect(cookie).not.toContain(validPayload().userId); expect(cookie).not.toContain("admin"); }); it("rejects when the ciphertext has been tampered with (auth tag mismatch)", async () => { const cookie = await signSession(validPayload(), SECRET); const [iv, ct] = cookie.split("."); // Flip the last character of the ciphertext (still valid base64url). const lastCh = ct!.slice(-1); const replacement = lastCh === "A" ? "B" : "A"; const tampered = `${iv}.${ct!.slice(0, -1)}${replacement}`; expect(await verifySession(tampered, SECRET, NOW)).toBeNull(); }); it("rejects when the IV has been swapped for another (auth tag mismatch)", async () => { const cookie = await signSession(validPayload(), SECRET); const otherIv = await signSession(validPayload(), SECRET); const [, ct] = cookie.split("."); const [otherIvB64] = otherIv.split("."); const tampered = `${otherIvB64}.${ct}`; 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("rejects a cookie whose IV decodes to the wrong byte length", async () => { // GCM requires a 12-byte nonce. Swap the IV portion for something // that decodes to a different length and confirm we bounce it // before handing weird input to crypto.subtle.decrypt. const cookie = await signSession(validPayload(), SECRET); const [, ct] = cookie.split("."); // 8 bytes encoded — too short. const shortIv = "AAAAAAAAAAA"; expect(await verifySession(`${shortIv}.${ct}`, SECRET, NOW)).toBeNull(); }); it("rejects malformed (non-base64url) input gracefully — no throw, just null", async () => { expect(await verifySession("$$$.$$$", SECRET, NOW)).toBeNull(); }); it("exposes COOKIE_NAME as 'session'", () => { expect(COOKIE_NAME).toBe("session"); }); });