fix(auth): make diagnostics bounded and portable

This commit is contained in:
2026-08-17 12:19:19 +02:00
parent 7cfbee36fa
commit be1724890a
23 changed files with 1088 additions and 116 deletions
+36 -14
View File
@@ -9,6 +9,7 @@ import {
} from "openid-client";
import { constants, createPublicKey, verify as verifySignature } from "node:crypto";
import { parseConfiguredTransportUrl } from "./url-policy.js";
import { isUsableAuthenticationSecret } from "./secret-policy.js";
export interface OidcIdentity {
issuer: string;
@@ -79,6 +80,23 @@ const MAX_JWKS_TIMEOUT_MS = 30_000;
const text = (value: unknown, maximum = 2048): value is string =>
typeof value === "string" && value.length > 0 && value.length <= maximum && !/\p{Cc}/u.test(value);
function discoveryStringList(value: unknown): value is readonly string[] {
return Array.isArray(value) && value.length > 0 && value.length <= 128
&& value.every((item) => text(item, 128));
}
function schemaValidDiscoveryMetadata(value: unknown): value is Record<string, unknown> & { issuer: string } {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const metadata = value as Record<string, unknown>;
return text(metadata.issuer, 2048)
&& text(metadata.authorization_endpoint, 2048)
&& text(metadata.token_endpoint, 2048)
&& text(metadata.jwks_uri, 2048)
&& discoveryStringList(metadata.response_types_supported)
&& discoveryStringList(metadata.subject_types_supported)
&& discoveryStringList(metadata.id_token_signing_alg_values_supported);
}
function configuredHttpsUrl(value: string): URL {
const url = parseConfiguredTransportUrl(value, { allowLoopbackHttp: false });
if (!url) throw new OidcProtocolError();
@@ -463,7 +481,7 @@ async function verifyJwksAvailability(
export function createOidcProtocol(options: OidcProtocolOptions): OidcProtocol {
const issuerUrl = configuredHttpsUrl(options.issuer);
const callbackUrl = configuredCallbackUrl(options.callbackUrl);
if (!text(options.clientId, 512) || !text(options.clientSecret, 4096)
if (!text(options.clientId, 512) || !isUsableAuthenticationSecret("THT_OIDC_CLIENT_SECRET", options.clientSecret)
|| !text(options.groupsClaim, 128) || options.scopes.length === 0 || options.scopes.length > 16
|| options.scopes.some((scope) => !text(scope, 128))
|| (options.httpTimeoutMs !== undefined && (!Number.isSafeInteger(options.httpTimeoutMs)
@@ -478,19 +496,24 @@ export function createOidcProtocol(options: OidcProtocolOptions): OidcProtocol {
const configuration = async (): Promise<Configuration> => {
if (!discovered) {
discovered = (async () => {
let inspectingDiscovery = true;
let certifiedIssuerMismatch = false;
const issuerCheckingFetch: CustomFetch = async (input, init) => {
const response = await transport.customFetch(input, init);
if (inspectingDiscovery) {
try {
const metadata = await response.clone().json() as { issuer?: unknown };
if (typeof metadata?.issuer === "string" && metadata.issuer !== options.issuer) {
throw new OidcIssuerMismatchError();
}
} catch (error) {
if (error instanceof OidcIssuerMismatchError) throw error;
// The OIDC library owns all other discovery-document validation.
if (!response.ok) return response;
try {
const metadata: unknown = await response.clone().json();
if (schemaValidDiscoveryMetadata(metadata) && metadata.issuer !== options.issuer) {
certifiedIssuerMismatch = true;
const headers = new Headers(response.headers);
headers.delete("content-length");
return new Response(JSON.stringify({ ...metadata, issuer: options.issuer }), {
status: response.status,
statusText: response.statusText,
headers,
});
}
} catch {
// The OIDC library owns malformed discovery-document classification.
}
return response;
};
@@ -503,15 +526,14 @@ export function createOidcProtocol(options: OidcProtocolOptions): OidcProtocol {
{ [customFetch]: issuerCheckingFetch, timeout: httpTimeoutMs / 1000 },
);
const metadata = config.serverMetadata();
if (metadata.issuer !== options.issuer) throw new OidcIssuerMismatchError();
if (certifiedIssuerMismatch) throw new OidcIssuerMismatchError();
if (metadata.issuer !== options.issuer) throw new OidcProtocolError();
httpsEndpoint(metadata.authorization_endpoint);
httpsEndpoint(metadata.token_endpoint);
httpsEndpoint(metadata.jwks_uri);
return config;
} catch (error) {
throw protocolFailure(error);
} finally {
inspectingDiscovery = false;
}
})();
}