import { expect } from "@std/expect"; import { beforeEach, describe, it as test } from "@std/testing/bdd"; import { FakeTime } from "@std/testing/time"; import { hmacHash } from "#crypto/hashing.ts"; import { base64ToBase64Url } from "#crypto/utils.ts"; import { getCurrentCsrfToken, isSignedCsrfToken, signCsrfToken, verifySignedCsrfToken, } from "#shared/csrf.ts"; import { setupTestEncryptionKey } from "#test-utils/env.ts"; import { withRequestContext } from "#test-utils/request-context.ts"; describe("signCsrfToken", () => { beforeEach(() => { setupTestEncryptionKey(); }); test("produces a token with s1. prefix", async () => { const token = await signCsrfToken(); expect(token.startsWith("s1.")).toBe(true); }); test("produces a token with four dot-separated parts", async () => { const token = await signCsrfToken(); const parts = token.split("."); expect(parts.length).toBe(4); }); test("includes a numeric timestamp", async () => { const token = await signCsrfToken(); const parts = token.split("."); const timestamp = Number.parseInt(parts[1]!, 10); expect(Number.isNaN(timestamp)).toBe(false); const nowS = Math.floor(Date.now() / 1000); expect(Math.abs(timestamp - nowS)).toBeLessThan(5); }); test("generates unique tokens on each call", async () => { const a = await signCsrfToken(); const b = await signCsrfToken(); expect(a).not.toBe(b); }); }); describe("isSignedCsrfToken", () => { test("returns true for signed tokens", async () => { setupTestEncryptionKey(); const token = await signCsrfToken(); expect(isSignedCsrfToken(token)).toBe(true); }); test("returns false for plain tokens", () => { expect(isSignedCsrfToken("abcdef123456")).toBe(false); }); test("returns false for empty string", () => { expect(isSignedCsrfToken("")).toBe(false); }); }); describe("verifySignedCsrfToken", () => { beforeEach(() => { setupTestEncryptionKey(); }); test("accepts a freshly signed token", async () => { const token = await signCsrfToken(); expect(await verifySignedCsrfToken(token)).toBe(true); }); test("rejects a token with tampered HMAC", async () => { const token = await signCsrfToken(); const tampered = `${token.slice(0, -4)}XXXX`; expect(await verifySignedCsrfToken(tampered)).toBe(false); }); test("rejects a token with tampered nonce", async () => { const token = await signCsrfToken(); const parts = token.split("."); parts[2] = "tampered-nonce-value"; expect(await verifySignedCsrfToken(parts.join("."))).toBe(false); }); test("rejects a token with tampered timestamp", async () => { const token = await signCsrfToken(); const parts = token.split("."); parts[1] = "9999999999"; expect(await verifySignedCsrfToken(parts.join("."))).toBe(false); }); test("rejects an expired token", async () => { const token = await signCsrfToken(); // Verify with maxAge=-1 so the token is already expired expect(await verifySignedCsrfToken(token, -1)).toBe(false); }); test("rejects a token once real elapsed time exceeds maxAge", async () => { using time = new FakeTime(1_000_000_000_000); const token = await signCsrfToken(); // Accepted while still inside the window... expect(await verifySignedCsrfToken(token, 60)).toBe(true); // ...then advance real time past maxAge so age (120s) > maxAge (60s). time.tick(120_000); expect(await verifySignedCsrfToken(token, 60)).toBe(false); }); test("rejects a token dated more than 60s in the future", async () => { // Sign under a far-future clock so the token's timestamp is well ahead // of "now"; FakeTime cannot move backwards, so verify under a separate // present-time clock. The gap (decades) far exceeds the 60s skew window. const signedInFuture = await (async () => { using _future = new FakeTime(4_000_000_000_000); return await signCsrfToken(); })(); using _present = new FakeTime(1_700_000_000_000); expect(await verifySignedCsrfToken(signedInFuture)).toBe(false); }); test("rejects a plain (unsigned) token", async () => { expect(await verifySignedCsrfToken("plain-random-token")).toBe(false); }); test("rejects an empty string", async () => { expect(await verifySignedCsrfToken("")).toBe(false); }); test("rejects a token with wrong number of parts", async () => { expect(await verifySignedCsrfToken("s1.only-two-parts")).toBe(false); expect(await verifySignedCsrfToken("s1.a.b.c.d.extra")).toBe(false); }); test("rejects a token with empty parts", async () => { expect(await verifySignedCsrfToken("s1..nonce.hmac")).toBe(false); }); test("rejects a correctly-signed token whose nonce is empty", async () => { // An attacker crafts a token with a valid HMAC but an empty nonce slot. // The signature itself verifies, so only the explicit empty-part guard // rejects it — a weaker guard would accept the malformed token. const timestamp = Math.floor(Date.now() / 1000); const message = `s1.${timestamp}.`; // buildMessage(timestamp, "") — empty nonce const hmac = base64ToBase64Url(await hmacHash(message)); const token = `s1.${timestamp}..${hmac}`; expect(await verifySignedCsrfToken(token)).toBe(false); }); test("rejects a token with non-numeric timestamp", async () => { expect(await verifySignedCsrfToken("s1.notanumber.nonce.hmac")).toBe(false); }); }); describe("getCurrentCsrfToken (request-scoped)", () => { beforeEach(() => { setupTestEncryptionKey(); }); test("returns the token signed in the current scope", async () => { const { signed, current } = await withRequestContext(async () => { const signed = await signCsrfToken(); return { current: getCurrentCsrfToken(), signed }; }); expect(current).toBe(signed); expect(isSignedCsrfToken(current)).toBe(true); }); test("re-signing within a scope replaces the rendered token", async () => { const { first, second, rendered } = await withRequestContext(async () => { const first = await signCsrfToken(); const second = await signCsrfToken(); return { first, rendered: getCurrentCsrfToken(), second }; }); expect(second).not.toBe(first); expect(rendered).toBe(second); // replaced outright, not appended to `first` }); test("concurrent request scopes each render their own token", async () => { const request = () => withRequestContext(async () => { const signed = await signCsrfToken(); await new Promise((r) => setTimeout(r, 20)); // render happens later return { rendered: getCurrentCsrfToken(), signed }; }); const [a, b] = await Promise.all([request(), request()]); // Each scope's rendered token matches the one it signed — no cross-request // bleed from a shared global. expect(a.rendered).toBe(a.signed); expect(b.rendered).toBe(b.signed); expect(a.rendered).not.toBe(b.rendered); }); });