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

459 lines
20 KiB
TypeScript

import type { WebConfig } from "@hivegrid/config";
import { Issuer, type AuthorizationParameters, type Client, type ClientMetadata, type TokenSet } from "openid-client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const configMocks = vi.hoisted(() => ({
loadRequiredWebConfig: vi.fn(),
}));
vi.mock("@hivegrid/config", () => ({
loadRequiredWebConfig: configMocks.loadRequiredWebConfig,
}));
import {
__resetKwpOidcClientForTests,
__setKwpOidcDiscoveryForTests,
buildKwpAuthorizationUrl,
exchangeKwpAuthorizationCode,
getKwpOidcClient,
KwpOidcClientError,
readKwpOidcRuntimeConfig,
} from "./kwpOidcClient.server";
const ISSUER = "https://id.kwp-online.de/realms/kwp-federated";
const AUTHORIZATION_ENDPOINT = `${ISSUER}/protocol/openid-connect/auth`;
const TOKEN_ENDPOINT = `${ISSUER}/protocol/openid-connect/token`;
const JWKS_URI = `${ISSUER}/protocol/openid-connect/certs`;
const CANONICAL_SCOPES = "openid adresse-extern archiv-extern termin-extern vorgang-extern aktivitaet-extern offline_access";
const SHUFFLED_SCOPES = " offline_access aktivitaet-extern openid vorgang-extern adresse-extern termin-extern archiv-extern ";
const SHUFFLED_RETURNED_SCOPES = "offline_access aktivitaet-extern openid vorgang-extern adresse-extern termin-extern archiv-extern";
const DOMAIN_SCOPES = "adresse-extern archiv-extern termin-extern vorgang-extern aktivitaet-extern";
const REDIRECT_URI = "https://app.example.test/integrations/kwp/oauth/callback";
type KwpWebConfig = Pick<WebConfig, "kwp" | "routing">;
type DiscoveredIssuer = Awaited<ReturnType<typeof Issuer.discover>>;
const INVALID_DISCOVERY_CONTRACTS: ReadonlyArray<readonly [string, Record<string, unknown>]> = [
["authorization endpoint", { authorization_endpoint: `${AUTHORIZATION_ENDPOINT}/unexpected` }],
["token endpoint", { token_endpoint: `${TOKEN_ENDPOINT}/unexpected` }],
["JWKS URI", { jwks_uri: `${JWKS_URI}/unexpected` }],
["PKCE S256 support", { code_challenge_methods_supported: ["plain"] }],
["authorization_code grant", { grant_types_supported: ["refresh_token"] }],
["refresh_token grant", { grant_types_supported: ["authorization_code"] }],
["client_secret_post authentication", { token_endpoint_auth_methods_supported: ["client_secret_basic"] }],
["advertised capability metadata", { code_challenge_methods_supported: undefined }],
];
function makeDiscoveryMetadata(overrides: Record<string, unknown> = {}): DiscoveredIssuer["metadata"] {
return {
issuer: ISSUER,
authorization_endpoint: AUTHORIZATION_ENDPOINT,
token_endpoint: TOKEN_ENDPOINT,
jwks_uri: JWKS_URI,
code_challenge_methods_supported: ["S256"],
grant_types_supported: ["authorization_code", "refresh_token"],
token_endpoint_auth_methods_supported: ["client_secret_post"],
...overrides,
};
}
function makeWebConfig(kwp: Partial<NonNullable<WebConfig["kwp"]>> = {}): KwpWebConfig {
return {
routing: {
appBaseDomain: "example.test",
webAppHost: "app.example.test",
adminAppHost: "admin.example.test",
apiHost: "api.example.test",
portalAppHost: "portal.example.test",
protocol: "https",
},
kwp: {
clientId: "kwp-client",
clientSecret: "kwp-secret",
scopes: SHUFFLED_SCOPES,
customerCode: "customer-code",
oidcIssuer: ISSUER,
hiveGridTenantId: "tenant-geerkens",
...kwp,
},
};
}
describe("KWP OIDC client", () => {
let authorizationUrl: ReturnType<typeof vi.fn<(parameters?: AuthorizationParameters) => string>>;
let callback: ReturnType<typeof vi.fn<Client["callback"]>>;
let client: Client;
let clientMetadata: ClientMetadata[];
let discover: ReturnType<typeof vi.fn<(issuer: string) => Promise<DiscoveredIssuer>>>;
beforeEach(() => {
__resetKwpOidcClientForTests();
vi.clearAllMocks();
vi.spyOn(Date, "now").mockReturnValue(1_899_999_940_000);
configMocks.loadRequiredWebConfig.mockReturnValue(makeWebConfig() as WebConfig);
clientMetadata = [];
authorizationUrl = vi.fn((parameters = {}) => {
const url = new URL(AUTHORIZATION_ENDPOINT);
for (const [name, value] of Object.entries(parameters)) {
if (value !== undefined) url.searchParams.set(name, String(value));
}
return url.toString();
});
callback = vi.fn();
client = { authorizationUrl, callback } as unknown as Client;
const ClientConstructor = vi.fn(function (metadata: ClientMetadata) {
clientMetadata.push(metadata);
return client;
});
discover = vi.fn(async () => ({ metadata: makeDiscoveryMetadata(), Client: ClientConstructor }) as unknown as DiscoveredIssuer);
__setKwpOidcDiscoveryForTests(discover);
});
afterEach(() => {
__resetKwpOidcClientForTests();
vi.restoreAllMocks();
});
test("requires all five provider values, derives the callback, and normalizes scope order", () => {
expect(readKwpOidcRuntimeConfig(makeWebConfig())).toEqual({
issuer: ISSUER,
clientId: "kwp-client",
clientSecret: "kwp-secret",
customerCode: "customer-code",
hiveGridTenantId: "tenant-geerkens",
scopes: CANONICAL_SCOPES,
redirectUri: REDIRECT_URI,
});
expect(() => readKwpOidcRuntimeConfig(makeWebConfig({ clientSecret: undefined }))).toThrowError(
expect.objectContaining({ code: "missing-config", message: expect.stringContaining("KWP_OAUTH_CLIENT_SECRET") })
);
expect(() => readKwpOidcRuntimeConfig(makeWebConfig({ hiveGridTenantId: undefined }))).toThrowError(
expect.objectContaining({ code: "missing-config", message: expect.stringContaining("KWP_HIVEGRID_TENANT_ID") })
);
});
test("ignores the deprecated process mandant and rejects malformed tenant-bound identifiers", () => {
expect(readKwpOidcRuntimeConfig(makeWebConfig({ mandant: " legacy-mandant " }))).toEqual(
readKwpOidcRuntimeConfig(makeWebConfig()),
);
for (const kwp of [
{ hiveGridTenantId: "tenant/id" },
{ hiveGridTenantId: `t${"_".repeat(256)}` },
{ customerCode: "customer.code" },
]) {
expect(() => readKwpOidcRuntimeConfig(makeWebConfig(kwp))).toThrowError(expect.objectContaining({ code: "invalid-input" }));
}
});
test("adds the technical openid and offline_access scopes to the five configured domain scopes", () => {
const config = makeWebConfig({ scopes: "termin-extern vorgang-extern adresse-extern aktivitaet-extern archiv-extern" });
expect(readKwpOidcRuntimeConfig(config).scopes).toBe(CANONICAL_SCOPES);
});
test("rejects every issuer except the exact approved federated realm", () => {
for (const oidcIssuer of [`${ISSUER}/`, "https://id.kwp-online.de/realms/other", "http://id.kwp-online.de/realms/kwp-federated"]) {
expect(() => readKwpOidcRuntimeConfig(makeWebConfig({ oidcIssuer }))).toThrowError(expect.objectContaining({ code: "invalid-issuer" }));
}
});
test.each([
["missing required scope", "openid adresse-extern archiv-extern termin-extern vorgang-extern offline_access"],
["unknown email scope", `${CANONICAL_SCOPES} email`],
["unapproved plural and project scopes", "openid adressen-extern aktivitaeten-extern archiv-extern projekt-extern termin-extern offline_access"],
])("rejects %s", (_name, scopes) => {
expect(() => readKwpOidcRuntimeConfig(makeWebConfig({ scopes }))).toThrowError(expect.objectContaining({ code: "invalid-scope" }));
});
test("discovers the exact issuer and caches a code-flow client using client_secret_post", async () => {
const first = await getKwpOidcClient();
const second = await getKwpOidcClient();
expect(first).toBe(client);
expect(second).toBe(client);
expect(discover).toHaveBeenCalledTimes(1);
expect(discover).toHaveBeenCalledWith(ISSUER);
expect(clientMetadata).toEqual([
{
client_id: "kwp-client",
client_secret: "kwp-secret",
redirect_uris: [REDIRECT_URI],
response_types: ["code"],
token_endpoint_auth_method: "client_secret_post",
},
]);
await getKwpOidcClient(readKwpOidcRuntimeConfig(makeWebConfig({ clientSecret: "rotated-secret" })));
expect(discover).toHaveBeenCalledTimes(2);
});
test("rejects discovery metadata whose issuer differs from the approved issuer", async () => {
__setKwpOidcDiscoveryForTests(
vi.fn(async () => ({ metadata: makeDiscoveryMetadata({ issuer: `${ISSUER}/` }), Client: vi.fn() }) as unknown as DiscoveredIssuer)
);
await expect(getKwpOidcClient()).rejects.toMatchObject({ code: "issuer-mismatch" });
});
test.each(INVALID_DISCOVERY_CONTRACTS)("rejects discovery metadata without the exact %s contract", async (_name, overrides) => {
const ClientConstructor = vi.fn();
__setKwpOidcDiscoveryForTests(
vi.fn(async () => ({ metadata: makeDiscoveryMetadata(overrides), Client: ClientConstructor }) as unknown as DiscoveredIssuer)
);
await expect(getKwpOidcClient()).rejects.toMatchObject({ code: "discovery-contract-mismatch" });
expect(ClientConstructor).not.toHaveBeenCalled();
});
test("builds authorization URL with canonical scopes, PKCE-S256, state, nonce, and tenant", async () => {
const result = await buildKwpAuthorizationUrl({
state: "state-value",
nonce: "nonce-value",
codeChallenge: "code-challenge-value",
customerCode: "customer-code",
});
expect(authorizationUrl).toHaveBeenCalledWith({
response_type: "code",
redirect_uri: REDIRECT_URI,
scope: CANONICAL_SCOPES,
state: "state-value",
nonce: "nonce-value",
code_challenge: "code-challenge-value",
code_challenge_method: "S256",
tenant: "customer-code",
});
const url = new URL(result);
expect(url.searchParams.get("scope")).toBe(CANONICAL_SCOPES);
expect(url.searchParams.get("tenant")).toBe("customer-code");
});
test("refuses an authorization customer code that differs from runtime config", async () => {
await expect(
buildKwpAuthorizationUrl({ state: "state-value", nonce: "nonce-value", codeChallenge: "challenge", customerCode: "other-customer" })
).rejects.toMatchObject({ code: "invalid-input" });
expect(authorizationUrl).not.toHaveBeenCalled();
});
test("exchanges and verifies callback parameters and returns compatible encrypted-credential fields", async () => {
callback.mockResolvedValue({
access_token: "access-token",
refresh_token: "refresh-token",
expires_at: 1_900_000_000,
scope: SHUFFLED_RETURNED_SCOPES,
} as TokenSet);
const result = await exchangeKwpAuthorizationCode(
{ code: "authorization-code", state: "state-value" },
{ state: "state-value", nonce: "nonce-value", codeVerifier: "code-verifier", customerCode: "customer-code" }
);
expect(callback).toHaveBeenCalledWith(
REDIRECT_URI,
{ code: "authorization-code", state: "state-value" },
{ state: "state-value", nonce: "nonce-value", code_verifier: "code-verifier" }
);
expect(result).toEqual({
credentials: {
accessToken: "access-token",
refreshToken: "refresh-token",
expiresAt: new Date(1_900_000_000 * 1_000).toISOString(),
issuer: ISSUER,
clientId: "kwp-client",
clientSecret: "kwp-secret",
customerCode: "customer-code",
scopes: CANONICAL_SCOPES.split(" "),
},
tokenExpiresAt: new Date(1_900_000_000 * 1_000),
});
});
test("passes the callback code and state to openid-client with all challenge checks", async () => {
const params = { code: "authorization-code", state: "state-value", iss: ISSUER };
callback.mockResolvedValue({
access_token: "access-token",
refresh_token: "refresh-token",
expires_at: 1_900_000_000,
} as TokenSet);
await exchangeKwpAuthorizationCode(params, {
state: "state-value",
nonce: "nonce-value",
codeVerifier: "code-verifier",
customerCode: "customer-code",
});
expect(callback).toHaveBeenCalledWith(REDIRECT_URI, params, {
state: "state-value",
nonce: "nonce-value",
code_verifier: "code-verifier",
});
});
test("accepts openid-client's elapsed 299-second expires_in getter", async () => {
callback.mockResolvedValue({
access_token: "access-token",
refresh_token: "refresh-token",
expires_at: 1_900_000_239,
get expires_in() {
return this.expires_at - Math.floor(Date.now() / 1_000);
},
} as TokenSet);
await expect(
exchangeKwpAuthorizationCode(
{ code: "authorization-code", state: "state-value" },
{ state: "state-value", nonce: "nonce-value", codeVerifier: "code-verifier", customerCode: "customer-code" }
)
).resolves.toMatchObject({ tokenExpiresAt: new Date(1_900_000_239 * 1_000) });
});
test("keeps the exchanged credential bundle unconfirmed until the user selects a mandant", async () => {
configMocks.loadRequiredWebConfig.mockReturnValue(makeWebConfig({ mandant: "mandant-1" }) as WebConfig);
callback.mockResolvedValue({
access_token: "access-token",
refresh_token: "refresh-token",
expires_at: 1_900_000_000,
scope: CANONICAL_SCOPES,
} as TokenSet);
const result = await exchangeKwpAuthorizationCode(
{ code: "authorization-code", state: "state-value" },
{ state: "state-value", nonce: "nonce-value", codeVerifier: "code-verifier", customerCode: "customer-code" }
);
expect(result.credentials).toMatchObject({ customerCode: "customer-code" });
expect(result.credentials).not.toHaveProperty("mandant");
expect(result.credentials).not.toHaveProperty("mandantBinding");
});
test.each([
["domain scopes only", DOMAIN_SCOPES],
["openid only as technical scope", `openid ${DOMAIN_SCOPES}`],
["offline_access only as technical scope", `${DOMAIN_SCOPES} offline_access`],
["both technical scopes", SHUFFLED_RETURNED_SCOPES],
["mixed repeated whitespace", `\topenid ${DOMAIN_SCOPES.replaceAll(" ", "\n ")}\toffline_access\n`],
])("accepts a callback scope echo with %s and persists the canonical policy", async (_name, scope) => {
callback.mockResolvedValue({
access_token: "access-token",
refresh_token: "refresh-token",
expires_at: 1_900_000_000,
scope,
} as TokenSet);
const result = await exchangeKwpAuthorizationCode(
{ code: "authorization-code", state: "state-value" },
{ state: "state-value", nonce: "nonce-value", codeVerifier: "code-verifier", customerCode: "customer-code" },
);
expect(result.credentials.scopes).toEqual(CANONICAL_SCOPES.split(" "));
});
test("accepts an omitted callback scope echo and persists the canonical policy", async () => {
callback.mockResolvedValue({
access_token: "access-token",
refresh_token: "refresh-token",
expires_at: 1_900_000_000,
} as TokenSet);
const result = await exchangeKwpAuthorizationCode(
{ code: "authorization-code", state: "state-value" },
{ state: "state-value", nonce: "nonce-value", codeVerifier: "code-verifier", customerCode: "customer-code" },
);
expect(result.credentials.scopes).toEqual(CANONICAL_SCOPES.split(" "));
});
test.each([
["missing access token", { refresh_token: "refresh", expires_at: 1_900_000_000, scope: CANONICAL_SCOPES }, "missing_access_token"],
["missing refresh token", { access_token: "access", expires_at: 1_900_000_000, scope: CANONICAL_SCOPES }, "missing_refresh_token"],
["missing expiry", { access_token: "access", refresh_token: "refresh", scope: CANONICAL_SCOPES }, "invalid_expiry"],
["oversized expires_in", { access_token: "access", refresh_token: "refresh", expires_in: 301, scope: CANONICAL_SCOPES }, "invalid_expiry"],
["fractional expires_in", { access_token: "access", refresh_token: "refresh", expires_in: 300.5, scope: CANONICAL_SCOPES }, "invalid_expiry"],
["negative expires_in", { access_token: "access", refresh_token: "refresh", expires_in: -1, scope: CANONICAL_SCOPES }, "invalid_expiry"],
[
"implausibly distant expires_at",
{ access_token: "access", refresh_token: "refresh", expires_at: 1_900_000_241, scope: CANONICAL_SCOPES },
"invalid_expiry",
],
["returned empty scope", { access_token: "access", refresh_token: "refresh", expires_at: 1_900_000_000, scope: "" }, "scope_mismatch"],
["returned whitespace-only scope", { access_token: "access", refresh_token: "refresh", expires_at: 1_900_000_000, scope: " \t\n " }, "scope_mismatch"],
[
"returned scope missing a domain scope",
{ access_token: "access", refresh_token: "refresh", expires_at: 1_900_000_000, scope: "adresse-extern archiv-extern termin-extern vorgang-extern" },
"scope_mismatch",
],
[
"unknown returned scope",
{ access_token: "access", refresh_token: "refresh", expires_at: 1_900_000_000, scope: `${CANONICAL_SCOPES} email` },
"scope_mismatch",
],
[
"duplicate returned scope",
{ access_token: "access", refresh_token: "refresh", expires_at: 1_900_000_000, scope: `${DOMAIN_SCOPES} adresse-extern` },
"scope_mismatch",
],
[
"wrongly capitalized returned scope",
{
access_token: "access",
refresh_token: "refresh",
expires_at: 1_900_000_000,
scope: "Adresse-extern archiv-extern termin-extern vorgang-extern aktivitaet-extern",
},
"scope_mismatch",
],
])("rejects a token response with %s", async (_name, tokenSet, diagnosticCode) => {
callback.mockResolvedValue(tokenSet as TokenSet);
await expect(
exchangeKwpAuthorizationCode(
{ code: "authorization-code", state: "state-value" },
{ state: "state-value", nonce: "nonce-value", codeVerifier: "code-verifier", customerCode: "customer-code" }
)
).rejects.toMatchObject({ code: "invalid-token-response", diagnosticCode });
});
test("wraps callback failures without exposing provider response details in the message", async () => {
callback.mockRejectedValue(Object.assign(new Error("authorization-code-secret"), { name: "OPError", error: "invalid_grant" }));
let error: unknown;
try {
await exchangeKwpAuthorizationCode(
{ code: "authorization-code", state: "state-value" },
{ state: "state-value", nonce: "nonce-value", codeVerifier: "code-verifier", customerCode: "customer-code" }
);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(KwpOidcClientError);
expect(error).toMatchObject({ code: "callback-failed", diagnosticCode: "provider_invalid_grant" });
expect((error as Error).message).not.toContain("authorization-code-secret");
});
test.each(["constructor", "toString"])("does not treat inherited provider error key %s as an allowlisted diagnostic", async (providerError) => {
callback.mockRejectedValue(Object.assign(new Error("provider-response-secret"), { error: providerError }));
await expect(
exchangeKwpAuthorizationCode(
{ code: "authorization-code", state: "state-value" },
{ state: "state-value", nonce: "nonce-value", codeVerifier: "code-verifier", customerCode: "customer-code" }
)
).rejects.toMatchObject({ code: "callback-failed", diagnosticCode: "token_exchange_failed" });
});
test("uses a neutral diagnostic for openid-client relying-party errors", async () => {
callback.mockRejectedValue(Object.assign(new Error("provider-response-secret"), { name: "RPError" }));
await expect(
exchangeKwpAuthorizationCode(
{ code: "authorization-code", state: "state-value" },
{ state: "state-value", nonce: "nonce-value", codeVerifier: "code-verifier", customerCode: "customer-code" }
)
).rejects.toMatchObject({ code: "callback-failed", diagnosticCode: "oidc_rp_error" });
});
});