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

423 lines
16 KiB
TypeScript

import { createHash } from "node:crypto";
import { loadRequiredWebConfig, type WebConfig } from "@hivegrid/config";
import {
KWP_OIDC_ISSUER,
KWP_REQUIRED_OAUTH_SCOPES,
isValidKwpContextCode,
isValidKwpReturnedOAuthScopes,
type KwpCredentials as IntegrationKwpCredentials,
} from "@hivegrid/integrations";
import { Issuer, type CallbackParamsType, type Client, type TokenSet } from "openid-client";
import { KWP_OAUTH_CALLBACK_PATH } from "~/utils/integrations/integrationsRoutePaths";
const KWP_AUTHORIZATION_ENDPOINT = `${KWP_OIDC_ISSUER}/protocol/openid-connect/auth`;
const KWP_TOKEN_ENDPOINT = `${KWP_OIDC_ISSUER}/protocol/openid-connect/token`;
const KWP_JWKS_URI = `${KWP_OIDC_ISSUER}/protocol/openid-connect/certs`;
const KWP_SCOPE_ORDER = KWP_REQUIRED_OAUTH_SCOPES;
const KWP_SCOPE_SET = new Set<string>(KWP_SCOPE_ORDER);
const KWP_DOMAIN_SCOPES = KWP_SCOPE_ORDER.filter((scope) => scope.endsWith("-extern"));
const HIVEGRID_TENANT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,255}$/u;
const KWP_ACCESS_TOKEN_TTL_SECONDS = 5 * 60;
type KwpOidcClientErrorCode =
| "missing-config"
| "invalid-issuer"
| "invalid-scope"
| "invalid-input"
| "discovery-failed"
| "issuer-mismatch"
| "discovery-contract-mismatch"
| "client-init-failed"
| "authorization-failed"
| "callback-failed"
| "invalid-token-response";
export type KwpOidcDiagnosticCode =
| "provider_access_denied"
| "provider_invalid_client"
| "provider_invalid_grant"
| "provider_invalid_scope"
| "provider_server_error"
| "provider_temporarily_unavailable"
| "provider_unauthorized_client"
| "oidc_rp_error"
| "token_exchange_failed"
| "missing_access_token"
| "missing_refresh_token"
| "invalid_expiry"
| "scope_mismatch";
export class KwpOidcClientError extends Error {
readonly code: KwpOidcClientErrorCode;
readonly diagnosticCode?: KwpOidcDiagnosticCode;
constructor(code: KwpOidcClientErrorCode, message: string, options?: { cause?: unknown; diagnosticCode?: KwpOidcDiagnosticCode }) {
super(message, options);
this.name = "KwpOidcClientError";
this.code = code;
this.diagnosticCode = options?.diagnosticCode;
}
}
export type KwpOidcRuntimeConfig = {
readonly issuer: typeof KWP_OIDC_ISSUER;
readonly clientId: string;
readonly clientSecret: string;
readonly customerCode: string;
readonly hiveGridTenantId: string;
readonly scopes: string;
readonly redirectUri: string;
};
export type KwpAuthorizationPayload = {
readonly state: string;
readonly nonce: string;
readonly codeChallenge: string;
readonly customerCode: string;
};
export type KwpAuthorizationCodeChecks = {
readonly state: string;
readonly nonce: string;
readonly codeVerifier: string;
readonly customerCode: string;
};
export type KwpAuthorizationCallbackParameters = {
readonly code: string;
readonly state: string;
readonly iss?: string;
};
export type KwpCredentials = IntegrationKwpCredentials;
export type KwpAuthorizationCodeExchangeResult = {
readonly credentials: KwpCredentials;
readonly tokenExpiresAt: Date;
};
type KwpWebConfig = Pick<WebConfig, "kwp" | "routing">;
type DiscoveredIssuer = Awaited<ReturnType<typeof Issuer.discover>>;
type DiscoverIssuer = (issuer: string) => Promise<DiscoveredIssuer>;
const defaultDiscoverIssuer: DiscoverIssuer = (issuer) => Issuer.discover(issuer);
let discoverIssuer: DiscoverIssuer = defaultDiscoverIssuer;
let cachedClient: Promise<Client> | null = null;
let cachedClientFingerprint: string | null = null;
/** Read and strictly validate the process-wide KWP OAuth provider contract. */
export function readKwpOidcRuntimeConfig(config: KwpWebConfig = loadRequiredWebConfig()): KwpOidcRuntimeConfig {
const clientId = present(config.kwp?.clientId);
const clientSecret = present(config.kwp?.clientSecret);
const rawScopes = present(config.kwp?.scopes);
const customerCode = present(config.kwp?.customerCode);
const oidcIssuer = present(config.kwp?.oidcIssuer);
const hiveGridTenantId = present(config.kwp?.hiveGridTenantId);
const missing = [
["KWP_OAUTH_CLIENT_ID", clientId],
["KWP_OAUTH_CLIENT_SECRET", clientSecret],
["KWP_OAUTH_SCOPES", rawScopes],
["KWP_CUSTOMER_CODE", customerCode],
["KWP_OIDC_ISSUER", oidcIssuer],
["KWP_HIVEGRID_TENANT_ID", hiveGridTenantId],
]
.filter((entry): entry is [string, undefined] => entry[1] === undefined)
.map(([name]) => name);
if (!clientId || !clientSecret || !rawScopes || !customerCode || !oidcIssuer || !hiveGridTenantId) {
throw new KwpOidcClientError("missing-config", `KWP OAuth configuration is incomplete: missing ${missing.join(", ")}.`);
}
if (oidcIssuer !== KWP_OIDC_ISSUER) {
throw new KwpOidcClientError("invalid-issuer", "KWP_OIDC_ISSUER must match the approved KWP federated realm exactly.");
}
if (!HIVEGRID_TENANT_ID_PATTERN.test(hiveGridTenantId) || !isValidKwpContextCode(customerCode)) {
throw new KwpOidcClientError("invalid-input", "The configured tenant or KWP context identifier is invalid.");
}
return {
issuer: KWP_OIDC_ISSUER,
clientId,
clientSecret,
customerCode,
hiveGridTenantId,
scopes: normalizeConfiguredScopes(rawScopes),
redirectUri: `${config.routing.protocol}://${config.routing.webAppHost}${KWP_OAUTH_CALLBACK_PATH}`,
};
}
/** Discover and cache the verified KWP OIDC client for the active configuration. */
export async function getKwpOidcClient(runtimeConfig: KwpOidcRuntimeConfig = readKwpOidcRuntimeConfig()): Promise<Client> {
const fingerprint = runtimeFingerprint(runtimeConfig);
if (cachedClient && cachedClientFingerprint === fingerprint) {
return cachedClient;
}
const pendingClient = initializeClient(runtimeConfig);
cachedClient = pendingClient;
cachedClientFingerprint = fingerprint;
try {
return await pendingClient;
} catch (error) {
if (cachedClient === pendingClient) {
cachedClient = null;
cachedClientFingerprint = null;
}
throw error;
}
}
/** Build the Authorization Code + PKCE request for KWP Keycloak. */
export async function buildKwpAuthorizationUrl(payload: KwpAuthorizationPayload): Promise<string> {
const runtimeConfig = readKwpOidcRuntimeConfig();
requireInput("state", payload.state);
requireInput("nonce", payload.nonce);
requireInput("codeChallenge", payload.codeChallenge);
requireInput("customerCode", payload.customerCode);
if (payload.customerCode !== runtimeConfig.customerCode) {
throw new KwpOidcClientError("invalid-input", "The KWP authorization customer code does not match the configured connection.");
}
const client = await getKwpOidcClient(runtimeConfig);
try {
return client.authorizationUrl({
response_type: "code",
redirect_uri: runtimeConfig.redirectUri,
scope: runtimeConfig.scopes,
state: payload.state,
nonce: payload.nonce,
code_challenge: payload.codeChallenge,
code_challenge_method: "S256",
tenant: runtimeConfig.customerCode,
});
} catch (cause) {
throw new KwpOidcClientError("authorization-failed", "The KWP authorization request could not be created.", { cause });
}
}
/** Exchange and verify the returned KWP authorization response. */
export async function exchangeKwpAuthorizationCode(
callbackInput: KwpAuthorizationCallbackParameters,
checks: KwpAuthorizationCodeChecks
): Promise<KwpAuthorizationCodeExchangeResult> {
const runtimeConfig = readKwpOidcRuntimeConfig();
requireInput("code", callbackInput.code);
requireInput("callback state", callbackInput.state);
requireInput("state", checks.state);
requireInput("nonce", checks.nonce);
requireInput("codeVerifier", checks.codeVerifier);
requireInput("customerCode", checks.customerCode);
if (checks.customerCode !== runtimeConfig.customerCode) {
throw new KwpOidcClientError("invalid-input", "The KWP callback customer code does not match the configured connection.");
}
const client = await getKwpOidcClient(runtimeConfig);
let tokenSet: TokenSet;
try {
const callbackParams: CallbackParamsType = {
code: callbackInput.code,
state: callbackInput.state,
...(callbackInput.iss ? { iss: callbackInput.iss } : {}),
};
tokenSet = await client.callback(runtimeConfig.redirectUri, callbackParams, {
state: checks.state,
nonce: checks.nonce,
code_verifier: checks.codeVerifier,
});
} catch (cause) {
throw new KwpOidcClientError("callback-failed", "The KWP authorization response could not be verified or exchanged.", {
cause,
diagnosticCode: callbackFailureDiagnosticCode(cause),
});
}
const accessToken = requiredToken(tokenSet.access_token, "access token", "missing_access_token");
const refreshToken = requiredToken(tokenSet.refresh_token, "refresh token", "missing_refresh_token");
const expiresAt = tokenExpiry(tokenSet);
if (tokenSet.scope !== undefined && !isValidKwpReturnedOAuthScopes(tokenSet.scope)) {
throw new KwpOidcClientError("invalid-token-response", "The returned KWP OAuth scope set is invalid.", {
diagnosticCode: "scope_mismatch",
});
}
return {
credentials: {
accessToken,
refreshToken,
expiresAt: expiresAt.toISOString(),
issuer: runtimeConfig.issuer,
clientId: runtimeConfig.clientId,
clientSecret: runtimeConfig.clientSecret,
customerCode: runtimeConfig.customerCode,
// Persist the canonical HiveGrid request/policy set, not Keycloak's
// permitted reduced scope echo from this individual token response.
scopes: [...KWP_REQUIRED_OAUTH_SCOPES],
},
tokenExpiresAt: expiresAt,
};
}
async function initializeClient(runtimeConfig: KwpOidcRuntimeConfig): Promise<Client> {
let discoveredIssuer: DiscoveredIssuer;
try {
discoveredIssuer = await discoverIssuer(runtimeConfig.issuer);
} catch (cause) {
throw new KwpOidcClientError("discovery-failed", "KWP OIDC discovery failed.", { cause });
}
if (discoveredIssuer.metadata.issuer !== runtimeConfig.issuer) {
throw new KwpOidcClientError("issuer-mismatch", "KWP OIDC discovery returned an unapproved issuer.");
}
validateDiscoveryContract(discoveredIssuer.metadata);
try {
return new discoveredIssuer.Client({
client_id: runtimeConfig.clientId,
client_secret: runtimeConfig.clientSecret,
redirect_uris: [runtimeConfig.redirectUri],
response_types: ["code"],
token_endpoint_auth_method: "client_secret_post",
});
} catch (cause) {
throw new KwpOidcClientError("client-init-failed", "The KWP OIDC client could not be initialized.", { cause });
}
}
function validateDiscoveryContract(metadata: DiscoveredIssuer["metadata"]): void {
const endpointsMatch =
metadata.authorization_endpoint === KWP_AUTHORIZATION_ENDPOINT && metadata.token_endpoint === KWP_TOKEN_ENDPOINT && metadata.jwks_uri === KWP_JWKS_URI;
const capabilitiesMatch =
metadataIncludes(metadata, "code_challenge_methods_supported", ["S256"]) &&
metadataIncludes(metadata, "grant_types_supported", ["authorization_code", "refresh_token"]) &&
metadataIncludes(metadata, "token_endpoint_auth_methods_supported", ["client_secret_post"]);
if (!endpointsMatch || !capabilitiesMatch) {
throw new KwpOidcClientError("discovery-contract-mismatch", "KWP OIDC discovery does not match the approved endpoint and capability contract.");
}
}
function metadataIncludes(metadata: DiscoveredIssuer["metadata"], field: string, requiredValues: readonly string[]): boolean {
const advertisedValues = metadata[field];
return Array.isArray(advertisedValues) && requiredValues.every((value) => advertisedValues.includes(value));
}
function runtimeFingerprint(runtimeConfig: KwpOidcRuntimeConfig): string {
const secretHash = createHash("sha256").update(runtimeConfig.clientSecret).digest("base64url");
return [
runtimeConfig.issuer,
runtimeConfig.clientId,
runtimeConfig.redirectUri,
runtimeConfig.customerCode,
runtimeConfig.hiveGridTenantId,
runtimeConfig.scopes,
secretHash,
].join("|");
}
function normalizeConfiguredScopes(rawScopes: string): string {
const scopes = rawScopes.split(/\s+/u).filter(Boolean);
const scopeSet = new Set(scopes);
const onlyApprovedScopes = [...scopeSet].every((scope) => KWP_SCOPE_SET.has(scope));
const includesEveryDomainScope = KWP_DOMAIN_SCOPES.every((scope) => scopeSet.has(scope));
const valid = onlyApprovedScopes && includesEveryDomainScope;
if (!valid) {
throw new KwpOidcClientError(
"invalid-scope",
"The configured KWP OAuth scopes must include all approved domain scopes and no unapproved scope.",
);
}
return KWP_SCOPE_ORDER.join(" ");
}
function requiredToken(value: string | undefined, label: string, diagnosticCode: KwpOidcDiagnosticCode): string {
const normalized = value?.trim();
if (!normalized) {
throw new KwpOidcClientError("invalid-token-response", `The KWP token response did not contain a ${label}.`, {
diagnosticCode,
});
}
return normalized;
}
function tokenExpiry(tokenSet: TokenSet): Date {
const nowSeconds = Math.floor(Date.now() / 1_000);
const expiresIn = tokenSet.expires_in;
if (
expiresIn !== undefined &&
(typeof expiresIn !== "number" || !Number.isSafeInteger(expiresIn) || expiresIn <= 0 || expiresIn > KWP_ACCESS_TOKEN_TTL_SECONDS)
) {
throw invalidExpiryError();
}
const expiresAtSeconds = tokenSet.expires_at ?? (expiresIn !== undefined ? nowSeconds + expiresIn : undefined);
const remainingSeconds = typeof expiresAtSeconds === "number" ? expiresAtSeconds - nowSeconds : Number.NaN;
if (
typeof expiresAtSeconds !== "number" ||
!Number.isSafeInteger(expiresAtSeconds) ||
!Number.isSafeInteger(remainingSeconds) ||
remainingSeconds <= 0 ||
remainingSeconds > KWP_ACCESS_TOKEN_TTL_SECONDS
) {
throw invalidExpiryError();
}
const expiresAt = new Date(expiresAtSeconds * 1_000);
if (Number.isNaN(expiresAt.getTime())) {
throw invalidExpiryError();
}
return expiresAt;
}
function invalidExpiryError(): KwpOidcClientError {
return new KwpOidcClientError("invalid-token-response", "The KWP token response did not contain a valid expiry.", {
diagnosticCode: "invalid_expiry",
});
}
const PROVIDER_ERROR_DIAGNOSTICS: Readonly<Record<string, KwpOidcDiagnosticCode>> = Object.freeze({
access_denied: "provider_access_denied",
invalid_client: "provider_invalid_client",
invalid_grant: "provider_invalid_grant",
invalid_scope: "provider_invalid_scope",
server_error: "provider_server_error",
temporarily_unavailable: "provider_temporarily_unavailable",
unauthorized_client: "provider_unauthorized_client",
});
function callbackFailureDiagnosticCode(cause: unknown): KwpOidcDiagnosticCode {
if (cause && typeof cause === "object") {
const providerError = "error" in cause ? cause.error : undefined;
if (typeof providerError === "string" && Object.hasOwn(PROVIDER_ERROR_DIAGNOSTICS, providerError)) {
return PROVIDER_ERROR_DIAGNOSTICS[providerError];
}
}
if (cause instanceof Error && cause.name === "RPError") {
return "oidc_rp_error";
}
return "token_exchange_failed";
}
function requireInput(name: string, value: string): void {
if (!present(value)) {
throw new KwpOidcClientError("invalid-input", `KWP OAuth ${name} is required.`);
}
}
function present(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
/** Test-only seam for deterministic discovery and client construction. */
export function __setKwpOidcDiscoveryForTests(discovery: DiscoverIssuer): void {
discoverIssuer = discovery;
cachedClient = null;
cachedClientFingerprint = null;
}
/** Test-only: restore real discovery and clear the process-wide client cache. */
export function __resetKwpOidcClientForTests(): void {
discoverIssuer = defaultDiscoverIssuer;
cachedClient = null;
cachedClientFingerprint = null;
}