186 lines
7.7 KiB
TypeScript
186 lines
7.7 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const { testWebConfig } = vi.hoisted(() => ({
|
|
testWebConfig: {
|
|
nodeEnv: "test" as string,
|
|
routing: {
|
|
appBaseDomain: "example.test",
|
|
webAppHost: "app.example.test",
|
|
adminAppHost: "admin.example.test",
|
|
apiHost: "api.example.test",
|
|
protocol: "https" as "http" | "https",
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock("@hivegrid/config", async () => {
|
|
const actual = await vi.importActual<typeof import("@hivegrid/config")>("@hivegrid/config");
|
|
return {
|
|
...actual,
|
|
loadRequiredWebConfig: () => testWebConfig,
|
|
};
|
|
});
|
|
|
|
import {
|
|
ADMIN_SSO_CHALLENGE_COOKIE,
|
|
ADMIN_SSO_CHALLENGE_TTL_MS,
|
|
consumeAdminSsoChallenge,
|
|
createAdminSsoChallenge,
|
|
LOCAL_ADMIN_SSO_CHALLENGE_COOKIE,
|
|
serializeAdminSsoChallengeCookie,
|
|
} from "./state.server";
|
|
|
|
const SECRET = "test-secret-do-not-use-in-prod";
|
|
|
|
function cookieValue(cookie: string): string {
|
|
const head = cookie.split(";")[0] ?? "";
|
|
return head.slice(`${ADMIN_SSO_CHALLENGE_COOKIE}=`.length);
|
|
}
|
|
|
|
function requestWithCookie(value: string, name = ADMIN_SSO_CHALLENGE_COOKIE): Request {
|
|
return new Request("https://admin.example.test/auth/admin/callback", {
|
|
headers: { cookie: `${name}=${value}` },
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
testWebConfig.nodeEnv = "test";
|
|
testWebConfig.routing.protocol = "https";
|
|
testWebConfig.routing.appBaseDomain = "example.test";
|
|
});
|
|
|
|
describe("createAdminSsoChallenge", () => {
|
|
it("produces distinct state, nonce and codeVerifier for each invocation", () => {
|
|
const a = createAdminSsoChallenge({ secret: SECRET });
|
|
const b = createAdminSsoChallenge({ secret: SECRET });
|
|
expect(a.payload.state).not.toBe(b.payload.state);
|
|
expect(a.payload.nonce).not.toBe(b.payload.nonce);
|
|
expect(a.payload.codeVerifier).not.toBe(b.payload.codeVerifier);
|
|
// state should be 32 random bytes hex-encoded -> 64 chars
|
|
expect(a.payload.state).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(a.payload.nonce).toMatch(/^[0-9a-f]{64}$/);
|
|
});
|
|
|
|
it("derives codeChallenge from codeVerifier (PKCE S256)", () => {
|
|
const { payload } = createAdminSsoChallenge({ secret: SECRET });
|
|
expect(payload.codeChallenge).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
expect(payload.codeChallenge).not.toBe(payload.codeVerifier);
|
|
});
|
|
|
|
it("emits a host-prefixed cookie on HTTPS without a Domain attribute", () => {
|
|
const { cookie } = createAdminSsoChallenge({ secret: SECRET });
|
|
expect(cookie).toContain(`${ADMIN_SSO_CHALLENGE_COOKIE}=`);
|
|
expect(cookie).toMatch(/HttpOnly/);
|
|
expect(cookie).toMatch(/Path=\//);
|
|
expect(cookie).toMatch(/SameSite=Lax/);
|
|
expect(cookie).toMatch(/Secure/);
|
|
expect(cookie).not.toMatch(/Domain=/);
|
|
});
|
|
|
|
it("uses a distinct localhost-compatible name on local HTTP", () => {
|
|
testWebConfig.nodeEnv = "development";
|
|
testWebConfig.routing.protocol = "http";
|
|
testWebConfig.routing.appBaseDomain = "localhost:3000";
|
|
|
|
const { cookie } = createAdminSsoChallenge({ secret: SECRET });
|
|
|
|
expect(cookie).toContain(`${LOCAL_ADMIN_SSO_CHALLENGE_COOKIE}=`);
|
|
expect(cookie).not.toContain(ADMIN_SSO_CHALLENGE_COOKIE);
|
|
expect(cookie).not.toContain("Secure");
|
|
expect(cookie).not.toContain("Domain=");
|
|
});
|
|
});
|
|
|
|
describe("consumeAdminSsoChallenge", () => {
|
|
it("round-trips a fresh challenge and returns a clear-cookie header", () => {
|
|
const { payload, cookie } = createAdminSsoChallenge({ secret: SECRET, returnTo: "/admin/dashboard" });
|
|
const value = cookieValue(cookie);
|
|
const req = requestWithCookie(value);
|
|
const result = consumeAdminSsoChallenge(req, { secret: SECRET });
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) {
|
|
expect(result.payload.state).toBe(payload.state);
|
|
expect(result.payload.nonce).toBe(payload.nonce);
|
|
expect(result.payload.codeVerifier).toBe(payload.codeVerifier);
|
|
expect(result.payload.returnTo).toBe("/admin/dashboard");
|
|
expect(result.clearCookie).toContain(`${ADMIN_SSO_CHALLENGE_COOKIE}=`);
|
|
expect(result.clearCookie).toContain("Max-Age=0");
|
|
}
|
|
});
|
|
|
|
it("reports `missing` when no cookie is attached", () => {
|
|
const req = new Request("https://admin.example.test/auth/admin/callback");
|
|
const result = consumeAdminSsoChallenge(req, { secret: SECRET });
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.reason).toBe("missing");
|
|
});
|
|
|
|
it("ignores a legacy parent-domain cookie on HTTPS", () => {
|
|
const { cookie } = createAdminSsoChallenge({ secret: SECRET });
|
|
const result = consumeAdminSsoChallenge(requestWithCookie(cookieValue(cookie), LOCAL_ADMIN_SSO_CHALLENGE_COOKIE), { secret: SECRET });
|
|
|
|
expect(result).toEqual({ ok: false, reason: "missing" });
|
|
});
|
|
|
|
it("does not let a legacy cookie shadow the host-prefixed challenge", () => {
|
|
const { payload, cookie } = createAdminSsoChallenge({ secret: SECRET });
|
|
const value = cookieValue(cookie);
|
|
const request = new Request("https://admin.example.test/auth/admin/callback", {
|
|
headers: { cookie: `${LOCAL_ADMIN_SSO_CHALLENGE_COOKIE}=attacker; ${ADMIN_SSO_CHALLENGE_COOKIE}=${value}` },
|
|
});
|
|
|
|
const result = consumeAdminSsoChallenge(request, { secret: SECRET });
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) expect(result.payload.state).toBe(payload.state);
|
|
});
|
|
|
|
it("rejects a cookie with a tampered signature", () => {
|
|
const { cookie } = createAdminSsoChallenge({ secret: SECRET });
|
|
const value = cookieValue(cookie);
|
|
const dot = value.lastIndexOf(".");
|
|
const tampered = value.slice(0, dot) + ".AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
|
const result = consumeAdminSsoChallenge(requestWithCookie(tampered), { secret: SECRET });
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.reason).toBe("bad-signature");
|
|
});
|
|
|
|
it("rejects a cookie signed under a different secret", () => {
|
|
const { cookie } = createAdminSsoChallenge({ secret: SECRET });
|
|
const result = consumeAdminSsoChallenge(requestWithCookie(cookieValue(cookie)), { secret: "different-secret" });
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.reason).toBe("bad-signature");
|
|
});
|
|
|
|
it("rejects an expired cookie (older than TTL)", () => {
|
|
const past = Date.now() - ADMIN_SSO_CHALLENGE_TTL_MS - 1000;
|
|
const { payload } = createAdminSsoChallenge({ secret: SECRET, now: () => past });
|
|
const cookie = serializeAdminSsoChallengeCookie(payload, SECRET);
|
|
const result = consumeAdminSsoChallenge(requestWithCookie(cookieValue(cookie)), { secret: SECRET });
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.reason).toBe("expired");
|
|
});
|
|
|
|
it("rejects a challenge dated implausibly far in the future", () => {
|
|
const now = Date.now();
|
|
const { payload } = createAdminSsoChallenge({ secret: SECRET, now: () => now + 5 * 60 * 1000 });
|
|
const cookie = serializeAdminSsoChallengeCookie(payload, SECRET);
|
|
const result = consumeAdminSsoChallenge(requestWithCookie(cookieValue(cookie)), { secret: SECRET, now: () => now });
|
|
expect(result).toEqual({ ok: false, reason: "malformed" });
|
|
});
|
|
|
|
it("rejects a signed payload whose PKCE verifier and challenge do not match", () => {
|
|
const { payload } = createAdminSsoChallenge({ secret: SECRET });
|
|
const inconsistent = { ...payload, codeChallenge: "A".repeat(43) };
|
|
const cookie = serializeAdminSsoChallengeCookie(inconsistent, SECRET);
|
|
const result = consumeAdminSsoChallenge(requestWithCookie(cookieValue(cookie)), { secret: SECRET });
|
|
expect(result).toEqual({ ok: false, reason: "malformed" });
|
|
});
|
|
|
|
it("rejects a malformed cookie value", () => {
|
|
const result = consumeAdminSsoChallenge(requestWithCookie("garbage"), { secret: SECRET });
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(["malformed", "bad-signature"]).toContain(result.reason);
|
|
});
|
|
});
|