259 lines
10 KiB
TypeScript
259 lines
10 KiB
TypeScript
import { createHash, createHmac } from "node:crypto";
|
|
|
|
import { createSharedStore, MemoryAdapter, type SharedStore } from "@hivegrid/shared-store";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const { testWebConfig } = vi.hoisted(() => ({
|
|
testWebConfig: {
|
|
nodeEnv: "test" as string,
|
|
cryptoSecret: "crypto-secret-for-kwp-oauth-tests",
|
|
sessionSecret: "session-secret-for-kwp-oauth-tests",
|
|
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 {
|
|
__resetKwpOAuthChallengeStoreForTests,
|
|
__setKwpOAuthChallengeStoreForTests,
|
|
consumeKwpOAuthChallenge,
|
|
createKwpOAuthChallenge,
|
|
KWP_OAUTH_CHALLENGE_COOKIE,
|
|
KWP_OAUTH_CHALLENGE_TTL_MS,
|
|
LOCAL_KWP_OAUTH_CHALLENGE_COOKIE,
|
|
} from "./kwpOAuthChallenge.server";
|
|
|
|
const SECRET = "kwp-oauth-state-test-secret";
|
|
const NOW = 1_725_000_000_000;
|
|
const INPUT = {
|
|
tenantId: "tenant-1",
|
|
tenantSlug: "acme-shk",
|
|
userId: "user-1",
|
|
connectionId: "connection-1",
|
|
connectionVersion: "2026-07-20T12:00:00.000Z",
|
|
customerCode: "geerkens2",
|
|
} as const;
|
|
|
|
let sharedStore: SharedStore;
|
|
|
|
beforeEach(() => {
|
|
testWebConfig.nodeEnv = "test";
|
|
testWebConfig.routing.protocol = "https";
|
|
testWebConfig.routing.appBaseDomain = "example.test";
|
|
sharedStore = createSharedStore({ namespace: "kwp-oauth-test", adapter: new MemoryAdapter() });
|
|
__setKwpOAuthChallengeStoreForTests(sharedStore);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
__resetKwpOAuthChallengeStoreForTests();
|
|
await sharedStore.close();
|
|
});
|
|
|
|
function cookieValue(cookie: string): string {
|
|
const assignment = cookie.split(";", 1)[0] ?? "";
|
|
return assignment.slice(assignment.indexOf("=") + 1);
|
|
}
|
|
|
|
function requestWithCookie(value: string, name = KWP_OAUTH_CHALLENGE_COOKIE): Request {
|
|
return new Request("https://app.example.test/integrations/kwp/oauth/callback", {
|
|
headers: { cookie: `${name}=${value}` },
|
|
});
|
|
}
|
|
|
|
function createChallenge(now = NOW) {
|
|
return createKwpOAuthChallenge(INPUT, { secret: SECRET, now: () => now });
|
|
}
|
|
|
|
function signedCookieFor(payload: unknown): string {
|
|
const body = Buffer.from(JSON.stringify({ v: "v2", p: payload }), "utf8").toString("base64url");
|
|
const signature = createHmac("sha256", SECRET).update(body).digest("base64url");
|
|
return `${body}.${signature}`;
|
|
}
|
|
|
|
describe("KWP OAuth challenge creation", () => {
|
|
it("creates the complete payload and derives PKCE exclusively with S256", () => {
|
|
const { payload } = createChallenge();
|
|
|
|
expect(payload).toMatchObject({ ...INPUT, createdAt: NOW });
|
|
expect(payload.state).toMatch(/^[0-9a-f]{64}$/u);
|
|
expect(payload.nonce).toMatch(/^[0-9a-f]{64}$/u);
|
|
expect(payload.codeVerifier).toMatch(/^[A-Za-z0-9._~-]{43,128}$/u);
|
|
expect(payload.codeChallenge).toBe(createHash("sha256").update(payload.codeVerifier, "ascii").digest("base64url"));
|
|
expect(payload.codeChallenge).not.toBe(payload.codeVerifier);
|
|
});
|
|
|
|
it("emits a host-prefixed HttpOnly SameSite=Lax cookie on HTTPS", () => {
|
|
const { cookie } = createChallenge();
|
|
|
|
expect(cookie).toContain(`${KWP_OAUTH_CHALLENGE_COOKIE}=`);
|
|
expect(cookie).toContain("Path=/");
|
|
expect(cookie).toContain("HttpOnly");
|
|
expect(cookie).toContain("SameSite=Lax");
|
|
expect(cookie).toContain("Secure");
|
|
expect(cookie).toContain("Max-Age=600");
|
|
expect(cookie).not.toContain("Domain=");
|
|
});
|
|
|
|
it("uses a distinct, host-only fallback cookie name on local HTTP", () => {
|
|
testWebConfig.nodeEnv = "development";
|
|
testWebConfig.routing.protocol = "http";
|
|
testWebConfig.routing.appBaseDomain = "localhost:3000";
|
|
|
|
const { cookie } = createChallenge();
|
|
|
|
expect(cookie).toContain(`${LOCAL_KWP_OAUTH_CHALLENGE_COOKIE}=`);
|
|
expect(cookie).not.toContain(KWP_OAUTH_CHALLENGE_COOKIE);
|
|
expect(cookie).not.toContain("Secure");
|
|
expect(cookie).not.toContain("Domain=");
|
|
});
|
|
});
|
|
|
|
describe("KWP OAuth challenge consumption", () => {
|
|
it("round-trips a fresh challenge and always returns a clear-cookie header", async () => {
|
|
const challenge = createChallenge();
|
|
|
|
const result = await consumeKwpOAuthChallenge(requestWithCookie(cookieValue(challenge.cookie)), challenge.payload.state, {
|
|
secret: SECRET,
|
|
now: () => NOW + 60_000,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
payload: challenge.payload,
|
|
clearCookie: expect.stringContaining(`${KWP_OAUTH_CHALLENGE_COOKIE}=`),
|
|
});
|
|
expect(result.clearCookie).toContain("Max-Age=0");
|
|
});
|
|
|
|
it("rejects a tampered signed body before trusting its tenant context", async () => {
|
|
const challenge = createChallenge();
|
|
const value = cookieValue(challenge.cookie);
|
|
const separator = value.lastIndexOf(".");
|
|
const tamperIndex = separator - 1;
|
|
const replacement = value[tamperIndex] === "A" ? "B" : "A";
|
|
const tampered = `${value.slice(0, tamperIndex)}${replacement}${value.slice(tamperIndex + 1)}`;
|
|
|
|
const result = await consumeKwpOAuthChallenge(requestWithCookie(tampered), challenge.payload.state, { secret: SECRET, now: () => NOW });
|
|
|
|
expect(result).toMatchObject({ ok: false, reason: "bad-signature" });
|
|
expect(result.clearCookie).toContain("Max-Age=0");
|
|
});
|
|
|
|
it.each([
|
|
["missing", new Request("https://app.example.test/integrations/kwp/oauth/callback"), "missing"],
|
|
["malformed", requestWithCookie("not-signed"), "malformed"],
|
|
["oversized", requestWithCookie("x".repeat(4_097)), "malformed"],
|
|
] as const)("rejects a %s cookie and still clears it", async (_case, request, reason) => {
|
|
const result = await consumeKwpOAuthChallenge(request, "a".repeat(64), { secret: SECRET, now: () => NOW });
|
|
|
|
expect(result).toMatchObject({ ok: false, reason });
|
|
expect(result.clearCookie).toContain("Max-Age=0");
|
|
});
|
|
|
|
it("rejects expired and implausibly future-dated challenges", async () => {
|
|
const expired = createChallenge();
|
|
const future = createChallenge(NOW + 60_001);
|
|
|
|
await expect(
|
|
consumeKwpOAuthChallenge(requestWithCookie(cookieValue(expired.cookie)), expired.payload.state, {
|
|
secret: SECRET,
|
|
now: () => NOW + KWP_OAUTH_CHALLENGE_TTL_MS,
|
|
}),
|
|
).resolves.toMatchObject({ ok: false, reason: "expired" });
|
|
await expect(
|
|
consumeKwpOAuthChallenge(requestWithCookie(cookieValue(future.cookie)), future.payload.state, { secret: SECRET, now: () => NOW }),
|
|
).resolves.toMatchObject({ ok: false, reason: "malformed" });
|
|
});
|
|
|
|
it("rejects a returned-state mismatch without consuming the valid nonce", async () => {
|
|
const challenge = createChallenge();
|
|
const request = requestWithCookie(cookieValue(challenge.cookie));
|
|
|
|
await expect(consumeKwpOAuthChallenge(request, "b".repeat(64), { secret: SECRET, now: () => NOW })).resolves.toMatchObject({
|
|
ok: false,
|
|
reason: "state-mismatch",
|
|
});
|
|
await expect(consumeKwpOAuthChallenge(request, challenge.payload.state, { secret: SECRET, now: () => NOW })).resolves.toMatchObject({ ok: true });
|
|
});
|
|
|
|
it("rejects validly signed payloads with extra, oversized, or inconsistent fields", async () => {
|
|
const { payload } = createChallenge();
|
|
const invalidPayloads: unknown[] = [
|
|
{ ...payload, unexpected: true },
|
|
{ ...payload, customerCode: "x".repeat(129) },
|
|
{ ...payload, customerCode: "geerkens.2" },
|
|
{ ...payload, connectionVersion: "2026-07-20" },
|
|
{ ...payload, codeChallenge: "A".repeat(43) },
|
|
];
|
|
|
|
for (const invalidPayload of invalidPayloads) {
|
|
await expect(
|
|
consumeKwpOAuthChallenge(requestWithCookie(signedCookieFor(invalidPayload)), payload.state, { secret: SECRET, now: () => NOW }),
|
|
).resolves.toMatchObject({ ok: false, reason: "malformed" });
|
|
}
|
|
});
|
|
|
|
it("claims the nonce with SET NX and the ten-minute TTL", async () => {
|
|
const set = vi.fn(async () => true);
|
|
__setKwpOAuthChallengeStoreForTests({ set });
|
|
const challenge = createChallenge();
|
|
|
|
await expect(
|
|
consumeKwpOAuthChallenge(requestWithCookie(cookieValue(challenge.cookie)), challenge.payload.state, { secret: SECRET, now: () => NOW }),
|
|
).resolves.toMatchObject({ ok: true });
|
|
|
|
expect(set).toHaveBeenCalledWith(expect.stringMatching(/^oauth:kwp:nonce:[0-9a-f]{64}$/u), expect.stringMatching(/^[0-9a-f]{64}$/u), {
|
|
nx: true,
|
|
ttlSeconds: 600,
|
|
});
|
|
expect(JSON.stringify(set.mock.calls)).not.toContain(challenge.payload.nonce);
|
|
});
|
|
|
|
it("rejects a sequential replay of the same nonce", async () => {
|
|
const challenge = createChallenge();
|
|
const request = requestWithCookie(cookieValue(challenge.cookie));
|
|
|
|
await expect(consumeKwpOAuthChallenge(request, challenge.payload.state, { secret: SECRET, now: () => NOW })).resolves.toMatchObject({ ok: true });
|
|
await expect(consumeKwpOAuthChallenge(request, challenge.payload.state, { secret: SECRET, now: () => NOW })).resolves.toMatchObject({
|
|
ok: false,
|
|
reason: "replayed",
|
|
});
|
|
});
|
|
|
|
it("allows exactly one of two parallel consumers to claim the nonce", async () => {
|
|
const challenge = createChallenge();
|
|
const request = requestWithCookie(cookieValue(challenge.cookie));
|
|
|
|
const results = await Promise.all([
|
|
consumeKwpOAuthChallenge(request, challenge.payload.state, { secret: SECRET, now: () => NOW }),
|
|
consumeKwpOAuthChallenge(request, challenge.payload.state, { secret: SECRET, now: () => NOW }),
|
|
]);
|
|
|
|
expect(results.filter((result) => result.ok)).toHaveLength(1);
|
|
expect(results.filter((result) => !result.ok && result.reason === "replayed")).toHaveLength(1);
|
|
expect(results.every((result) => result.clearCookie.includes("Max-Age=0"))).toBe(true);
|
|
});
|
|
|
|
it("fails closed when the distributed replay store is unavailable", async () => {
|
|
__setKwpOAuthChallengeStoreForTests({ set: () => Promise.reject(new Error("redis unavailable")) });
|
|
const challenge = createChallenge();
|
|
|
|
await expect(
|
|
consumeKwpOAuthChallenge(requestWithCookie(cookieValue(challenge.cookie)), challenge.payload.state, { secret: SECRET, now: () => NOW }),
|
|
).resolves.toMatchObject({ ok: false, reason: "store-unavailable" });
|
|
});
|
|
});
|