Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/kwp/services/kwpOAuthChallenge.server.test.ts
T

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" });
});
});