659 lines
26 KiB
TypeScript
659 lines
26 KiB
TypeScript
import {
|
|
authorizationCodeGrant,
|
|
buildAuthorizationUrl,
|
|
calculatePKCECodeChallenge,
|
|
customFetch,
|
|
discovery,
|
|
initiateDeviceAuthorization,
|
|
pollDeviceAuthorizationGrant,
|
|
type Configuration,
|
|
type CustomFetch,
|
|
} 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;
|
|
subject: string;
|
|
displayName?: string;
|
|
groups: readonly string[];
|
|
tokenExpiresAt: Date;
|
|
}
|
|
|
|
export interface OidcProtocol {
|
|
authorizationUrl(input: { state: string; nonce: string; codeVerifier: string }): Promise<URL>;
|
|
callback(input: { currentUrl: URL; state: string; nonce: string; codeVerifier: string }): Promise<OidcIdentity>;
|
|
diagnose(signal: AbortSignal): Promise<void>;
|
|
verifyDeviceFlow?(signal: AbortSignal, present: (uri: string, code: string) => void): Promise<OidcIdentity>;
|
|
}
|
|
|
|
export class OidcProtocolError extends Error {
|
|
constructor(message = "oidc_protocol_invalid") {
|
|
super(message);
|
|
this.name = "OidcProtocolError";
|
|
}
|
|
}
|
|
|
|
/** A safe operational distinction for callers and diagnostics; provider details never cross this boundary. */
|
|
export class OidcProviderUnavailableError extends OidcProtocolError {
|
|
constructor() {
|
|
super("oidc_provider_unavailable");
|
|
this.name = "OidcProviderUnavailableError";
|
|
}
|
|
}
|
|
|
|
/** The discovery document resolved, but its signed-token key set could not be certified. */
|
|
export class OidcJwksUnavailableError extends OidcProtocolError {
|
|
constructor() {
|
|
super("oidc_jwks_unreachable");
|
|
this.name = "OidcJwksUnavailableError";
|
|
}
|
|
}
|
|
|
|
/** Discovery completed with metadata for a different issuer than the configured trust anchor. */
|
|
export class OidcIssuerMismatchError extends OidcProtocolError {
|
|
constructor() {
|
|
super("oidc_issuer_mismatch");
|
|
this.name = "OidcIssuerMismatchError";
|
|
}
|
|
}
|
|
|
|
/** Device authorization is optional OIDC metadata and must never fall back to a browser flow. */
|
|
export class OidcDeviceFlowUnavailableError extends OidcProtocolError {
|
|
constructor() {
|
|
super("oidc_device_flow_unavailable");
|
|
this.name = "OidcDeviceFlowUnavailableError";
|
|
}
|
|
}
|
|
|
|
export interface OidcProtocolOptions {
|
|
issuer: string;
|
|
clientId: string;
|
|
clientSecret: string;
|
|
callbackUrl: string;
|
|
scopes: readonly string[];
|
|
groupsClaim: string;
|
|
fetch?: typeof globalThis.fetch;
|
|
httpTimeoutMs?: number;
|
|
jwksTimeoutMs?: number;
|
|
}
|
|
|
|
const MAX_GROUPS = 128;
|
|
const MAX_GROUP_LENGTH = 256;
|
|
const MAX_ID_TOKEN_LENGTH = 16 * 1024;
|
|
const MAX_OIDC_RESPONSE_BYTES = 1024 * 1024;
|
|
const DEFAULT_HTTP_TIMEOUT_MS = 5_000;
|
|
const MAX_HTTP_TIMEOUT_MS = 30_000;
|
|
const DEFAULT_JWKS_TIMEOUT_MS = 5_000;
|
|
const MAX_JWKS_TIMEOUT_MS = 30_000;
|
|
const MAX_DEVICE_FLOW_TIMEOUT_MS = 10 * 60_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>;
|
|
const issuer = metadata.issuer;
|
|
const authorizationEndpoint = metadata.authorization_endpoint;
|
|
const tokenEndpoint = metadata.token_endpoint;
|
|
const jwksUri = metadata.jwks_uri;
|
|
if (!text(issuer, 2048) || !text(authorizationEndpoint, 2048)
|
|
|| !text(tokenEndpoint, 2048) || !text(jwksUri, 2048)
|
|
|| !discoveryStringList(metadata.response_types_supported)
|
|
|| !discoveryStringList(metadata.subject_types_supported)
|
|
|| !discoveryStringList(metadata.id_token_signing_alg_values_supported)) return false;
|
|
try {
|
|
configuredHttpsUrl(issuer);
|
|
httpsEndpoint(authorizationEndpoint);
|
|
httpsEndpoint(tokenEndpoint);
|
|
httpsEndpoint(jwksUri);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function configuredHttpsUrl(value: string): URL {
|
|
const url = parseConfiguredTransportUrl(value, { allowLoopbackHttp: false });
|
|
if (!url) throw new OidcProtocolError();
|
|
return url;
|
|
}
|
|
|
|
function configuredCallbackUrl(value: string): URL {
|
|
const url = parseConfiguredTransportUrl(value, { allowLoopbackHttp: true });
|
|
if (!url) throw new OidcProtocolError();
|
|
return url;
|
|
}
|
|
|
|
function httpsEndpoint(value: unknown): URL {
|
|
if (!text(value, 2048)) throw new OidcProtocolError();
|
|
let url: URL;
|
|
try {
|
|
url = new URL(value);
|
|
} catch {
|
|
throw new OidcProtocolError();
|
|
}
|
|
if (url.protocol !== "https:" || url.username || url.password || url.hash) throw new OidcProtocolError();
|
|
return url;
|
|
}
|
|
|
|
function groupsFromClaims(claims: Record<string, unknown>, name: string): string[] {
|
|
const indirect = claims._claim_names;
|
|
if ((indirect && typeof indirect === "object" && !Array.isArray(indirect)
|
|
&& Object.prototype.hasOwnProperty.call(indirect, name))
|
|
|| claims.hasgroups === true) throw new OidcProtocolError();
|
|
const raw = claims[name];
|
|
if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_GROUPS) throw new OidcProtocolError();
|
|
const groups: string[] = [];
|
|
const unique = new Set<string>();
|
|
for (const group of raw) {
|
|
if (!text(group, MAX_GROUP_LENGTH) || group.trim().length === 0 || unique.has(group)) throw new OidcProtocolError();
|
|
unique.add(group);
|
|
groups.push(group);
|
|
}
|
|
return groups;
|
|
}
|
|
|
|
function identityFromClaims(claims: Record<string, unknown>, options: OidcProtocolOptions): OidcIdentity {
|
|
if (claims.iss !== options.issuer || !text(claims.sub, 512)) throw new OidcProtocolError();
|
|
const audience = claims.aud;
|
|
if (!(audience === options.clientId || (Array.isArray(audience) && audience.includes(options.clientId)))) {
|
|
throw new OidcProtocolError();
|
|
}
|
|
if (typeof claims.exp !== "number" || !Number.isSafeInteger(claims.exp) || claims.exp * 1000 <= Date.now()) {
|
|
throw new OidcProtocolError();
|
|
}
|
|
const tokenExpiresAt = new Date(claims.exp * 1000);
|
|
if (Number.isNaN(tokenExpiresAt.getTime())) throw new OidcProtocolError();
|
|
return {
|
|
issuer: options.issuer,
|
|
subject: claims.sub,
|
|
...(text(claims.name, 256) ? { displayName: claims.name } : {}),
|
|
groups: groupsFromClaims(claims, options.groupsClaim),
|
|
tokenExpiresAt,
|
|
};
|
|
}
|
|
|
|
function jsonPart(part: string): Record<string, unknown> {
|
|
if (!/^[A-Za-z0-9_-]+$/.test(part) || part.length > MAX_ID_TOKEN_LENGTH) throw new OidcProtocolError();
|
|
try {
|
|
const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(part, "base64url")));
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new OidcProtocolError();
|
|
return value as Record<string, unknown>;
|
|
} catch {
|
|
throw new OidcProtocolError();
|
|
}
|
|
}
|
|
|
|
function signatureAlgorithm(algorithm: string): {
|
|
digest: string | null;
|
|
pss?: boolean;
|
|
saltLength?: number;
|
|
ecdsaPartLength?: number;
|
|
} {
|
|
switch (algorithm) {
|
|
case "RS256": return { digest: "RSA-SHA256" };
|
|
case "RS384": return { digest: "RSA-SHA384" };
|
|
case "RS512": return { digest: "RSA-SHA512" };
|
|
case "PS256": return { digest: "sha256", pss: true, saltLength: 32 };
|
|
case "PS384": return { digest: "sha384", pss: true, saltLength: 48 };
|
|
case "PS512": return { digest: "sha512", pss: true, saltLength: 64 };
|
|
case "ES256": return { digest: "sha256", ecdsaPartLength: 32 };
|
|
case "ES384": return { digest: "sha384", ecdsaPartLength: 48 };
|
|
case "ES512": return { digest: "sha512", ecdsaPartLength: 66 };
|
|
case "EdDSA": return { digest: null };
|
|
default: throw new OidcProtocolError();
|
|
}
|
|
}
|
|
|
|
function derLength(length: number): Buffer {
|
|
if (length < 128) return Buffer.from([length]);
|
|
if (length < 256) return Buffer.from([0x81, length]);
|
|
throw new OidcProtocolError();
|
|
}
|
|
|
|
function derInteger(raw: Buffer): Buffer {
|
|
let start = 0;
|
|
while (start < raw.length - 1 && raw[start] === 0) start += 1;
|
|
let value = raw.subarray(start);
|
|
if ((value[0] & 0x80) !== 0) value = Buffer.concat([Buffer.from([0]), value]);
|
|
return Buffer.concat([Buffer.from([0x02]), derLength(value.length), value]);
|
|
}
|
|
|
|
function joseEcdsaSignatureToDer(signature: Buffer, partLength: number): Buffer {
|
|
if (signature.length !== partLength * 2) throw new OidcProtocolError();
|
|
const sequence = Buffer.concat([
|
|
derInteger(signature.subarray(0, partLength)),
|
|
derInteger(signature.subarray(partLength)),
|
|
]);
|
|
return Buffer.concat([Buffer.from([0x30]), derLength(sequence.length), sequence]);
|
|
}
|
|
|
|
class OidcTransportError extends Error {
|
|
constructor(readonly availability: boolean) {
|
|
super(availability ? "oidc_provider_unavailable" : "oidc_provider_response_invalid");
|
|
this.name = "OidcTransportError";
|
|
}
|
|
}
|
|
|
|
interface BoundedOidcTransport {
|
|
customFetch: CustomFetch;
|
|
request(
|
|
input: RequestInfo | URL,
|
|
init?: RequestInit,
|
|
options?: { timeoutMs?: number; requireSuccess?: boolean },
|
|
): Promise<Response>;
|
|
}
|
|
|
|
function cancelReaderBestEffort(reader: ReadableStreamDefaultReader<Uint8Array>): void {
|
|
try {
|
|
void Promise.resolve(reader.cancel()).catch(() => undefined);
|
|
} catch {
|
|
// Cancellation is advisory; the deadline and rejection remain authoritative.
|
|
}
|
|
}
|
|
|
|
function cancelResponseBestEffort(response: Response): void {
|
|
if (!response.body) return;
|
|
try {
|
|
void Promise.resolve(response.body.cancel()).catch(() => undefined);
|
|
} catch {
|
|
// A late response is never allowed to turn an already-bounded request into an unhandled rejection.
|
|
}
|
|
}
|
|
|
|
function abortedReason(signal: AbortSignal): unknown {
|
|
return signal.reason ?? new DOMException("The operation was aborted", "AbortError");
|
|
}
|
|
|
|
async function awaitWithAbort<T>(
|
|
operation: Promise<T>,
|
|
signal: AbortSignal,
|
|
onLateResolution?: (value: T) => void,
|
|
): Promise<T> {
|
|
return await new Promise<T>((resolve, reject) => {
|
|
let settled = signal.aborted;
|
|
const cleanup = () => signal.removeEventListener("abort", aborted);
|
|
const aborted = () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
cleanup();
|
|
reject(abortedReason(signal));
|
|
};
|
|
if (signal.aborted) reject(abortedReason(signal));
|
|
else signal.addEventListener("abort", aborted, { once: true });
|
|
operation.then(
|
|
(value) => {
|
|
if (settled) {
|
|
try { onLateResolution?.(value); } catch { /* best-effort late cleanup only */ }
|
|
return;
|
|
}
|
|
settled = true;
|
|
cleanup();
|
|
resolve(value);
|
|
},
|
|
(error: unknown) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
cleanup();
|
|
reject(error);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
function providerRequestUrl(input: RequestInfo | URL): URL {
|
|
const value = input instanceof URL ? input.href : input instanceof Request ? input.url : input;
|
|
return httpsEndpoint(value);
|
|
}
|
|
|
|
function declaredResponseLength(response: Response): number | undefined {
|
|
const declared = response.headers.get("content-length");
|
|
if (declared === null) return undefined;
|
|
if (!/^\d+$/.test(declared)) throw new OidcTransportError(false);
|
|
const length = Number(declared);
|
|
if (!Number.isSafeInteger(length) || length > MAX_OIDC_RESPONSE_BYTES) throw new OidcTransportError(false);
|
|
return length;
|
|
}
|
|
|
|
function safeBufferedResponse(response: Response, body: Buffer): Response {
|
|
const noBodyStatus = response.status === 204 || response.status === 205 || response.status === 304;
|
|
// Node accepts Buffer as a fetch body; the DOM declaration in this project does not model it.
|
|
return new Response(noBodyStatus ? null : body as unknown as BodyInit, {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers: response.headers,
|
|
});
|
|
}
|
|
|
|
async function boundedResponse(
|
|
response: Response,
|
|
signal: AbortSignal,
|
|
requireSuccess: boolean,
|
|
): Promise<Response> {
|
|
const reader = response.body?.getReader();
|
|
const chunks: Buffer[] = [];
|
|
let total = 0;
|
|
let completed = false;
|
|
try {
|
|
if (response.redirected || response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) {
|
|
throw new OidcTransportError(false);
|
|
}
|
|
if (requireSuccess && !response.ok) throw new OidcTransportError(false);
|
|
declaredResponseLength(response);
|
|
if (!reader) {
|
|
completed = true;
|
|
return safeBufferedResponse(response, Buffer.alloc(0));
|
|
}
|
|
while (true) {
|
|
const { done, value } = await awaitWithAbort(reader.read(), signal);
|
|
if (done) break;
|
|
if (value.byteLength > MAX_OIDC_RESPONSE_BYTES - total) throw new OidcTransportError(false);
|
|
total += value.byteLength;
|
|
chunks.push(Buffer.from(value));
|
|
}
|
|
completed = true;
|
|
return safeBufferedResponse(response, Buffer.concat(chunks, total));
|
|
} finally {
|
|
try {
|
|
if (!completed && reader) cancelReaderBestEffort(reader);
|
|
} finally {
|
|
try { reader?.releaseLock(); } catch { /* cancellation already made the response unusable */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
function createBoundedOidcTransport(
|
|
fetchImplementation: typeof globalThis.fetch,
|
|
defaultTimeoutMs: number,
|
|
): BoundedOidcTransport {
|
|
const request: BoundedOidcTransport["request"] = async (input, init, requestOptions) => {
|
|
let target: URL;
|
|
try {
|
|
target = providerRequestUrl(input);
|
|
} catch {
|
|
throw new OidcTransportError(false);
|
|
}
|
|
const timeoutMs = requestOptions?.timeoutMs ?? defaultTimeoutMs;
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
timeout.unref();
|
|
const signal = init?.signal ? AbortSignal.any([init.signal, controller.signal]) : controller.signal;
|
|
try {
|
|
const requestInit: RequestInit = { ...init, redirect: "manual", signal };
|
|
const response = await awaitWithAbort(
|
|
Promise.resolve().then(() => fetchImplementation(target, requestInit)),
|
|
signal,
|
|
cancelResponseBestEffort,
|
|
);
|
|
return await boundedResponse(response, signal, requestOptions?.requireSuccess === true);
|
|
} catch (error) {
|
|
if (error instanceof OidcTransportError) throw error;
|
|
if (signal.aborted) throw new OidcTransportError(true);
|
|
throw new OidcTransportError(true);
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
};
|
|
return {
|
|
request,
|
|
// openid-client's FetchBody is Fetch-compatible, but comes from a distinct declaration graph.
|
|
customFetch: (url, options) => request(url, options as unknown as RequestInit),
|
|
};
|
|
}
|
|
|
|
function availabilityFailure(error: unknown): boolean {
|
|
let current = error;
|
|
const seen = new Set<object>();
|
|
for (let depth = 0; depth < 8; depth += 1) {
|
|
if (current instanceof OidcTransportError) return current.availability;
|
|
if (!current || typeof current !== "object" || seen.has(current)) return false;
|
|
seen.add(current);
|
|
current = (current as { cause?: unknown }).cause;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function protocolFailure(error: unknown): OidcProtocolError {
|
|
let current = error;
|
|
const seen = new Set<object>();
|
|
for (let depth = 0; depth < 8; depth += 1) {
|
|
if (current instanceof OidcProtocolError) return current;
|
|
if (!current || typeof current !== "object" || seen.has(current)) break;
|
|
seen.add(current);
|
|
current = (current as { cause?: unknown }).cause;
|
|
}
|
|
return availabilityFailure(error) ? new OidcProviderUnavailableError() : new OidcProtocolError();
|
|
}
|
|
|
|
async function verifyIdTokenSignature(
|
|
idToken: unknown,
|
|
config: Configuration,
|
|
transport: BoundedOidcTransport,
|
|
jwksTimeoutMs: number,
|
|
): Promise<void> {
|
|
if (!text(idToken, MAX_ID_TOKEN_LENGTH)) throw new OidcProtocolError();
|
|
const [protectedPart, payloadPart, signaturePart, extra] = idToken.split(".");
|
|
if (!protectedPart || !payloadPart || !signaturePart || extra) throw new OidcProtocolError();
|
|
const header = jsonPart(protectedPart);
|
|
if (!text(header.alg, 16) || !text(header.kid, 256)) throw new OidcProtocolError();
|
|
const metadata = config.serverMetadata();
|
|
if (!Array.isArray(metadata.id_token_signing_alg_values_supported)
|
|
|| !metadata.id_token_signing_alg_values_supported.includes(header.alg)
|
|
|| !text(metadata.jwks_uri, 2048)) throw new OidcProtocolError();
|
|
const jwksUrl = httpsEndpoint(metadata.jwks_uri);
|
|
try {
|
|
const response = await transport.request(
|
|
jwksUrl,
|
|
{ headers: { accept: "application/json" }, redirect: "manual" },
|
|
{ timeoutMs: jwksTimeoutMs, requireSuccess: true },
|
|
);
|
|
const body = new TextDecoder("utf-8", { fatal: true }).decode(await response.arrayBuffer());
|
|
const parsed = JSON.parse(body) as { keys?: unknown };
|
|
if (!Array.isArray(parsed.keys) || parsed.keys.length === 0 || parsed.keys.length > 16) throw new OidcProtocolError();
|
|
const matching = parsed.keys.filter((key): key is Record<string, unknown> =>
|
|
Boolean(key) && typeof key === "object" && !Array.isArray(key) && key.kid === header.kid);
|
|
if (matching.length !== 1) throw new OidcProtocolError();
|
|
const key = matching[0];
|
|
if (key.use !== undefined && key.use !== "sig") throw new OidcProtocolError();
|
|
if (key.alg !== undefined && key.alg !== header.alg) throw new OidcProtocolError();
|
|
const algorithm = signatureAlgorithm(header.alg);
|
|
if (!/^[A-Za-z0-9_-]+$/.test(signaturePart)) throw new OidcProtocolError();
|
|
const signature = Buffer.from(signaturePart, "base64url");
|
|
if (signature.length === 0) throw new OidcProtocolError();
|
|
const publicKey = createPublicKey({ key: key as never, format: "jwk" });
|
|
const normalizedSignature = algorithm.ecdsaPartLength
|
|
? joseEcdsaSignatureToDer(signature, algorithm.ecdsaPartLength)
|
|
: signature;
|
|
const verified = algorithm.pss
|
|
? verifySignature(algorithm.digest, Buffer.from(`${protectedPart}.${payloadPart}`), {
|
|
key: publicKey, padding: constants.RSA_PKCS1_PSS_PADDING, saltLength: algorithm.saltLength,
|
|
}, normalizedSignature)
|
|
: verifySignature(algorithm.digest, Buffer.from(`${protectedPart}.${payloadPart}`), publicKey, normalizedSignature);
|
|
if (!verified) throw new OidcProtocolError();
|
|
} catch (error) {
|
|
throw protocolFailure(error);
|
|
}
|
|
}
|
|
|
|
async function verifyJwksAvailability(
|
|
config: Configuration,
|
|
transport: BoundedOidcTransport,
|
|
jwksTimeoutMs: number,
|
|
signal: AbortSignal,
|
|
): Promise<void> {
|
|
const metadata = config.serverMetadata();
|
|
if (!text(metadata.jwks_uri, 2048)) throw new OidcProtocolError();
|
|
const response = await transport.request(
|
|
httpsEndpoint(metadata.jwks_uri),
|
|
{ headers: { accept: "application/json" }, redirect: "manual", signal },
|
|
{ timeoutMs: jwksTimeoutMs, requireSuccess: true },
|
|
);
|
|
const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(await response.arrayBuffer()));
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)
|
|
|| !Array.isArray((parsed as { keys?: unknown }).keys)) throw new OidcProtocolError();
|
|
}
|
|
|
|
export function createOidcProtocol(options: OidcProtocolOptions): OidcProtocol {
|
|
const issuerUrl = configuredHttpsUrl(options.issuer);
|
|
const callbackUrl = configuredCallbackUrl(options.callbackUrl);
|
|
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)
|
|
|| options.httpTimeoutMs < 1 || options.httpTimeoutMs > MAX_HTTP_TIMEOUT_MS))
|
|
|| (options.jwksTimeoutMs !== undefined && (!Number.isSafeInteger(options.jwksTimeoutMs)
|
|
|| options.jwksTimeoutMs < 1 || options.jwksTimeoutMs > MAX_JWKS_TIMEOUT_MS))) throw new OidcProtocolError();
|
|
const httpTimeoutMs = options.httpTimeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS;
|
|
const jwksTimeoutMs = options.jwksTimeoutMs ?? DEFAULT_JWKS_TIMEOUT_MS;
|
|
const transport = createBoundedOidcTransport(options.fetch ?? globalThis.fetch, httpTimeoutMs);
|
|
|
|
let discovered: Promise<Configuration> | undefined;
|
|
const configuration = async (): Promise<Configuration> => {
|
|
if (!discovered) {
|
|
discovered = (async () => {
|
|
let certifiedIssuerMismatch = false;
|
|
const issuerCheckingFetch: CustomFetch = async (input, init) => {
|
|
const response = await transport.customFetch(input, init);
|
|
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;
|
|
};
|
|
try {
|
|
const config = await discovery(
|
|
issuerUrl,
|
|
options.clientId,
|
|
{ client_secret: options.clientSecret, redirect_uris: [callbackUrl.href], response_types: ["code"] },
|
|
undefined,
|
|
{ [customFetch]: issuerCheckingFetch, timeout: httpTimeoutMs / 1000 },
|
|
);
|
|
const metadata = config.serverMetadata();
|
|
if (metadata.issuer !== options.issuer) throw new OidcProtocolError();
|
|
httpsEndpoint(metadata.authorization_endpoint);
|
|
httpsEndpoint(metadata.token_endpoint);
|
|
httpsEndpoint(metadata.jwks_uri);
|
|
if (certifiedIssuerMismatch) throw new OidcIssuerMismatchError();
|
|
return config;
|
|
} catch (error) {
|
|
throw protocolFailure(error);
|
|
}
|
|
})();
|
|
}
|
|
return await discovered;
|
|
};
|
|
|
|
return {
|
|
async authorizationUrl(input) {
|
|
try {
|
|
const challenge = await calculatePKCECodeChallenge(input.codeVerifier);
|
|
return buildAuthorizationUrl(await configuration(), {
|
|
response_type: "code",
|
|
redirect_uri: callbackUrl.href,
|
|
scope: options.scopes.join(" "),
|
|
state: input.state,
|
|
nonce: input.nonce,
|
|
code_challenge: challenge,
|
|
code_challenge_method: "S256",
|
|
});
|
|
} catch (error) {
|
|
throw protocolFailure(error);
|
|
}
|
|
},
|
|
async callback(input) {
|
|
if (input.currentUrl.origin !== callbackUrl.origin || input.currentUrl.pathname !== callbackUrl.pathname) {
|
|
throw new OidcProtocolError();
|
|
}
|
|
try {
|
|
const config = await configuration();
|
|
const tokens = await authorizationCodeGrant(config, input.currentUrl, {
|
|
expectedState: input.state,
|
|
expectedNonce: input.nonce,
|
|
pkceCodeVerifier: input.codeVerifier,
|
|
idTokenExpected: true,
|
|
});
|
|
await verifyIdTokenSignature(tokens.id_token, config, transport, jwksTimeoutMs);
|
|
const claims = tokens.claims();
|
|
if (!claims || Array.isArray(claims)) throw new OidcProtocolError();
|
|
return identityFromClaims(claims as Record<string, unknown>, options);
|
|
} catch (error) {
|
|
throw protocolFailure(error);
|
|
}
|
|
},
|
|
async diagnose(signal) {
|
|
signal.throwIfAborted();
|
|
const config = await configuration();
|
|
signal.throwIfAborted();
|
|
try {
|
|
await verifyJwksAvailability(config, transport, jwksTimeoutMs, signal);
|
|
} catch {
|
|
throw new OidcJwksUnavailableError();
|
|
}
|
|
signal.throwIfAborted();
|
|
},
|
|
async verifyDeviceFlow(signal, present) {
|
|
const maximumDeadline = AbortSignal.timeout(MAX_DEVICE_FLOW_TIMEOUT_MS);
|
|
const operationSignal = AbortSignal.any([signal, maximumDeadline]);
|
|
let config: Configuration;
|
|
try {
|
|
operationSignal.throwIfAborted();
|
|
config = await configuration();
|
|
operationSignal.throwIfAborted();
|
|
const endpoint = config.serverMetadata().device_authorization_endpoint;
|
|
httpsEndpoint(endpoint);
|
|
} catch (error) {
|
|
if (error instanceof OidcIssuerMismatchError || error instanceof OidcProviderUnavailableError) throw error;
|
|
throw new OidcDeviceFlowUnavailableError();
|
|
}
|
|
try {
|
|
operationSignal.throwIfAborted();
|
|
const device = await awaitWithAbort(
|
|
initiateDeviceAuthorization(config, { scope: options.scopes.join(" ") }),
|
|
operationSignal,
|
|
);
|
|
if (!text(device.verification_uri, 2048) || !text(device.user_code, 256)) {
|
|
throw new OidcDeviceFlowUnavailableError();
|
|
}
|
|
const providerLifetimeMs = device.expires_in * 1000;
|
|
if (!Number.isSafeInteger(providerLifetimeMs) || providerLifetimeMs <= 0) {
|
|
throw new OidcDeviceFlowUnavailableError();
|
|
}
|
|
const deviceSignal = AbortSignal.any([
|
|
signal,
|
|
maximumDeadline,
|
|
AbortSignal.timeout(Math.min(providerLifetimeMs, MAX_DEVICE_FLOW_TIMEOUT_MS)),
|
|
]);
|
|
const verificationUri = httpsEndpoint(device.verification_uri);
|
|
present(verificationUri.href, device.user_code);
|
|
const tokens = await pollDeviceAuthorizationGrant(config, device, undefined, { signal: deviceSignal });
|
|
await verifyIdTokenSignature(tokens.id_token, config, transport, jwksTimeoutMs);
|
|
const claims = tokens.claims();
|
|
if (!claims || Array.isArray(claims)) throw new OidcProtocolError();
|
|
return identityFromClaims(claims as Record<string, unknown>, options);
|
|
} catch (error) {
|
|
if (error instanceof OidcDeviceFlowUnavailableError) throw error;
|
|
throw protocolFailure(error);
|
|
}
|
|
},
|
|
};
|
|
}
|