423 lines
16 KiB
TypeScript
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;
|
|
}
|