459 lines
20 KiB
TypeScript
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" });
|
|
});
|
|
});
|