fix(auth): bound all OIDC provider exchanges
This commit is contained in:
+197
-56
@@ -5,6 +5,7 @@ import {
|
|||||||
customFetch,
|
customFetch,
|
||||||
discovery,
|
discovery,
|
||||||
type Configuration,
|
type Configuration,
|
||||||
|
type CustomFetch,
|
||||||
} from "openid-client";
|
} from "openid-client";
|
||||||
import { constants, createPublicKey, verify as verifySignature } from "node:crypto";
|
import { constants, createPublicKey, verify as verifySignature } from "node:crypto";
|
||||||
import { parseConfiguredTransportUrl } from "./url-policy.js";
|
import { parseConfiguredTransportUrl } from "./url-policy.js";
|
||||||
@@ -25,8 +26,17 @@ export interface OidcProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class OidcProtocolError extends Error {
|
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() {
|
constructor() {
|
||||||
super("oidc_protocol_invalid");
|
super("oidc_provider_unavailable");
|
||||||
|
this.name = "OidcProviderUnavailableError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,13 +48,16 @@ export interface OidcProtocolOptions {
|
|||||||
scopes: readonly string[];
|
scopes: readonly string[];
|
||||||
groupsClaim: string;
|
groupsClaim: string;
|
||||||
fetch?: typeof globalThis.fetch;
|
fetch?: typeof globalThis.fetch;
|
||||||
|
httpTimeoutMs?: number;
|
||||||
jwksTimeoutMs?: number;
|
jwksTimeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_GROUPS = 128;
|
const MAX_GROUPS = 128;
|
||||||
const MAX_GROUP_LENGTH = 256;
|
const MAX_GROUP_LENGTH = 256;
|
||||||
const MAX_ID_TOKEN_LENGTH = 16 * 1024;
|
const MAX_ID_TOKEN_LENGTH = 16 * 1024;
|
||||||
const MAX_JWKS_BYTES = 1024 * 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 DEFAULT_JWKS_TIMEOUT_MS = 5_000;
|
||||||
const MAX_JWKS_TIMEOUT_MS = 30_000;
|
const MAX_JWKS_TIMEOUT_MS = 30_000;
|
||||||
const text = (value: unknown, maximum = 2048): value is string =>
|
const text = (value: unknown, maximum = 2048): value is string =>
|
||||||
@@ -166,70 +179,201 @@ function joseEcdsaSignatureToDer(signature: Buffer, partLength: number): Buffer
|
|||||||
return Buffer.concat([Buffer.from([0x30]), derLength(sequence.length), sequence]);
|
return Buffer.concat([Buffer.from([0x30]), derLength(sequence.length), sequence]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readWithAbort(
|
class OidcTransportError extends Error {
|
||||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
constructor(readonly availability: boolean) {
|
||||||
signal: AbortSignal,
|
super(availability ? "oidc_provider_unavailable" : "oidc_provider_response_invalid");
|
||||||
): Promise<ReadableStreamReadResult<Uint8Array>> {
|
this.name = "OidcTransportError";
|
||||||
signal.throwIfAborted();
|
}
|
||||||
return await new Promise((resolve, reject) => {
|
}
|
||||||
const aborted = () => {
|
|
||||||
cancelReaderBestEffort(reader);
|
interface BoundedOidcTransport {
|
||||||
reject(signal.reason);
|
customFetch: CustomFetch;
|
||||||
};
|
request(
|
||||||
signal.addEventListener("abort", aborted, { once: true });
|
input: RequestInfo | URL,
|
||||||
reader.read().then(resolve, reject).finally(() => signal.removeEventListener("abort", aborted));
|
init?: RequestInit,
|
||||||
});
|
options?: { timeoutMs?: number; requireSuccess?: boolean },
|
||||||
|
): Promise<Response>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelReaderBestEffort(reader: ReadableStreamDefaultReader<Uint8Array>): void {
|
function cancelReaderBestEffort(reader: ReadableStreamDefaultReader<Uint8Array>): void {
|
||||||
try {
|
try {
|
||||||
void reader.cancel().catch(() => undefined);
|
void Promise.resolve(reader.cancel()).catch(() => undefined);
|
||||||
} catch {
|
} catch {
|
||||||
// Cancellation is advisory; the bounded fetch timeout/rejection remains authoritative.
|
// Cancellation is advisory; the deadline and rejection remain authoritative.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function boundedJwksBody(response: Response, signal: AbortSignal): Promise<string> {
|
function cancelResponseBestEffort(response: Response): void {
|
||||||
if (!response.body) throw new OidcProtocolError();
|
if (!response.body) return;
|
||||||
const reader = response.body.getReader();
|
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[] = [];
|
const chunks: Buffer[] = [];
|
||||||
let total = 0;
|
let total = 0;
|
||||||
let completed = false;
|
let completed = false;
|
||||||
try {
|
try {
|
||||||
if (!response.ok) throw new OidcProtocolError();
|
if (response.redirected || response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) {
|
||||||
const declaredLength = response.headers.get("content-length");
|
throw new OidcTransportError(false);
|
||||||
if (declaredLength !== null) {
|
}
|
||||||
if (!/^\d+$/.test(declaredLength)) throw new OidcProtocolError();
|
if (requireSuccess && !response.ok) throw new OidcTransportError(false);
|
||||||
const length = Number(declaredLength);
|
declaredResponseLength(response);
|
||||||
if (!Number.isSafeInteger(length) || length > MAX_JWKS_BYTES) throw new OidcProtocolError();
|
if (!reader) {
|
||||||
|
completed = true;
|
||||||
|
return safeBufferedResponse(response, Buffer.alloc(0));
|
||||||
}
|
}
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await readWithAbort(reader, signal);
|
const { done, value } = await awaitWithAbort(reader.read(), signal);
|
||||||
if (done) break;
|
if (done) break;
|
||||||
if (value.byteLength > MAX_JWKS_BYTES - total) throw new OidcProtocolError();
|
if (value.byteLength > MAX_OIDC_RESPONSE_BYTES - total) throw new OidcTransportError(false);
|
||||||
total += value.byteLength;
|
total += value.byteLength;
|
||||||
chunks.push(Buffer.from(value));
|
chunks.push(Buffer.from(value));
|
||||||
}
|
}
|
||||||
completed = true;
|
completed = true;
|
||||||
|
return safeBufferedResponse(response, Buffer.concat(chunks, total));
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
if (!completed) cancelReaderBestEffort(reader);
|
if (!completed && reader) cancelReaderBestEffort(reader);
|
||||||
} finally {
|
} finally {
|
||||||
try { reader.releaseLock(); } catch { /* cancellation already made the response unusable */ }
|
try { reader?.releaseLock(); } catch { /* cancellation already made the response unusable */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
signal.throwIfAborted();
|
}
|
||||||
try {
|
|
||||||
return new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks, total));
|
function createBoundedOidcTransport(
|
||||||
} catch {
|
fetchImplementation: typeof globalThis.fetch,
|
||||||
throw new OidcProtocolError();
|
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 {
|
||||||
|
if (error instanceof OidcProtocolError) return error;
|
||||||
|
return availabilityFailure(error) ? new OidcProviderUnavailableError() : new OidcProtocolError();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verifyIdTokenSignature(
|
async function verifyIdTokenSignature(
|
||||||
idToken: unknown,
|
idToken: unknown,
|
||||||
config: Configuration,
|
config: Configuration,
|
||||||
options: OidcProtocolOptions,
|
transport: BoundedOidcTransport,
|
||||||
|
jwksTimeoutMs: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!text(idToken, MAX_ID_TOKEN_LENGTH)) throw new OidcProtocolError();
|
if (!text(idToken, MAX_ID_TOKEN_LENGTH)) throw new OidcProtocolError();
|
||||||
const [protectedPart, payloadPart, signaturePart, extra] = idToken.split(".");
|
const [protectedPart, payloadPart, signaturePart, extra] = idToken.split(".");
|
||||||
@@ -241,15 +385,13 @@ async function verifyIdTokenSignature(
|
|||||||
|| !metadata.id_token_signing_alg_values_supported.includes(header.alg)
|
|| !metadata.id_token_signing_alg_values_supported.includes(header.alg)
|
||||||
|| !text(metadata.jwks_uri, 2048)) throw new OidcProtocolError();
|
|| !text(metadata.jwks_uri, 2048)) throw new OidcProtocolError();
|
||||||
const jwksUrl = httpsEndpoint(metadata.jwks_uri);
|
const jwksUrl = httpsEndpoint(metadata.jwks_uri);
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), options.jwksTimeoutMs ?? DEFAULT_JWKS_TIMEOUT_MS);
|
|
||||||
timeout.unref();
|
|
||||||
let response: Response;
|
|
||||||
try {
|
try {
|
||||||
response = await (options.fetch ?? globalThis.fetch)(jwksUrl, {
|
const response = await transport.request(
|
||||||
headers: { accept: "application/json" }, redirect: "error", signal: controller.signal,
|
jwksUrl,
|
||||||
});
|
{ headers: { accept: "application/json" }, redirect: "manual" },
|
||||||
const body = await boundedJwksBody(response, controller.signal);
|
{ timeoutMs: jwksTimeoutMs, requireSuccess: true },
|
||||||
|
);
|
||||||
|
const body = new TextDecoder("utf-8", { fatal: true }).decode(await response.arrayBuffer());
|
||||||
const parsed = JSON.parse(body) as { keys?: unknown };
|
const parsed = JSON.parse(body) as { keys?: unknown };
|
||||||
if (!Array.isArray(parsed.keys) || parsed.keys.length === 0 || parsed.keys.length > 16) throw new OidcProtocolError();
|
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> =>
|
const matching = parsed.keys.filter((key): key is Record<string, unknown> =>
|
||||||
@@ -273,10 +415,7 @@ async function verifyIdTokenSignature(
|
|||||||
: verifySignature(algorithm.digest, Buffer.from(`${protectedPart}.${payloadPart}`), publicKey, normalizedSignature);
|
: verifySignature(algorithm.digest, Buffer.from(`${protectedPart}.${payloadPart}`), publicKey, normalizedSignature);
|
||||||
if (!verified) throw new OidcProtocolError();
|
if (!verified) throw new OidcProtocolError();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof OidcProtocolError) throw error;
|
throw protocolFailure(error);
|
||||||
throw new OidcProtocolError();
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,8 +425,13 @@ export function createOidcProtocol(options: OidcProtocolOptions): OidcProtocol {
|
|||||||
if (!text(options.clientId, 512) || !text(options.clientSecret, 4096)
|
if (!text(options.clientId, 512) || !text(options.clientSecret, 4096)
|
||||||
|| !text(options.groupsClaim, 128) || options.scopes.length === 0 || options.scopes.length > 16
|
|| !text(options.groupsClaim, 128) || options.scopes.length === 0 || options.scopes.length > 16
|
||||||
|| options.scopes.some((scope) => !text(scope, 128))
|
|| 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 !== undefined && (!Number.isSafeInteger(options.jwksTimeoutMs)
|
||||||
|| options.jwksTimeoutMs < 1 || options.jwksTimeoutMs > MAX_JWKS_TIMEOUT_MS))) throw new OidcProtocolError();
|
|| 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;
|
let discovered: Promise<Configuration> | undefined;
|
||||||
const configuration = async (): Promise<Configuration> => {
|
const configuration = async (): Promise<Configuration> => {
|
||||||
@@ -299,7 +443,7 @@ export function createOidcProtocol(options: OidcProtocolOptions): OidcProtocol {
|
|||||||
options.clientId,
|
options.clientId,
|
||||||
{ client_secret: options.clientSecret, redirect_uris: [callbackUrl.href], response_types: ["code"] },
|
{ client_secret: options.clientSecret, redirect_uris: [callbackUrl.href], response_types: ["code"] },
|
||||||
undefined,
|
undefined,
|
||||||
options.fetch ? { [customFetch]: options.fetch as never } : undefined,
|
{ [customFetch]: transport.customFetch, timeout: httpTimeoutMs / 1000 },
|
||||||
);
|
);
|
||||||
const metadata = config.serverMetadata();
|
const metadata = config.serverMetadata();
|
||||||
if (metadata.issuer !== options.issuer) throw new OidcProtocolError();
|
if (metadata.issuer !== options.issuer) throw new OidcProtocolError();
|
||||||
@@ -308,8 +452,7 @@ export function createOidcProtocol(options: OidcProtocolOptions): OidcProtocol {
|
|||||||
httpsEndpoint(metadata.jwks_uri);
|
httpsEndpoint(metadata.jwks_uri);
|
||||||
return config;
|
return config;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof OidcProtocolError) throw error;
|
throw protocolFailure(error);
|
||||||
throw new OidcProtocolError();
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
@@ -330,8 +473,7 @@ export function createOidcProtocol(options: OidcProtocolOptions): OidcProtocol {
|
|||||||
code_challenge_method: "S256",
|
code_challenge_method: "S256",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof OidcProtocolError) throw error;
|
throw protocolFailure(error);
|
||||||
throw new OidcProtocolError();
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async callback(input) {
|
async callback(input) {
|
||||||
@@ -346,13 +488,12 @@ export function createOidcProtocol(options: OidcProtocolOptions): OidcProtocol {
|
|||||||
pkceCodeVerifier: input.codeVerifier,
|
pkceCodeVerifier: input.codeVerifier,
|
||||||
idTokenExpected: true,
|
idTokenExpected: true,
|
||||||
});
|
});
|
||||||
await verifyIdTokenSignature(tokens.id_token, config, options);
|
await verifyIdTokenSignature(tokens.id_token, config, transport, jwksTimeoutMs);
|
||||||
const claims = tokens.claims();
|
const claims = tokens.claims();
|
||||||
if (!claims || Array.isArray(claims)) throw new OidcProtocolError();
|
if (!claims || Array.isArray(claims)) throw new OidcProtocolError();
|
||||||
return identityFromClaims(claims as Record<string, unknown>, options);
|
return identityFromClaims(claims as Record<string, unknown>, options);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof OidcProtocolError) throw error;
|
throw protocolFailure(error);
|
||||||
throw new OidcProtocolError();
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async diagnose(signal) {
|
async diagnose(signal) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { afterEach, expect, test, vi } from "vitest";
|
|||||||
import { registerAuthRoutes } from "../src/auth/routes.js";
|
import { registerAuthRoutes } from "../src/auth/routes.js";
|
||||||
import type { LoadedAuthConfig, OidcStateRecord } from "../src/auth/types.js";
|
import type { LoadedAuthConfig, OidcStateRecord } from "../src/auth/types.js";
|
||||||
import type { AuthSessionStore } from "../src/auth/session-store.js";
|
import type { AuthSessionStore } from "../src/auth/session-store.js";
|
||||||
import type { OidcProtocol } from "../src/auth/oidc-client.js";
|
import { createOidcProtocol, type OidcProtocol } from "../src/auth/oidc-client.js";
|
||||||
|
|
||||||
const revision = "a".repeat(64);
|
const revision = "a".repeat(64);
|
||||||
const issuer = "https://issuer.example.test";
|
const issuer = "https://issuer.example.test";
|
||||||
@@ -95,6 +95,7 @@ function stateRecord(extra: Partial<OidcStateRecord> = {}): OidcStateRecord {
|
|||||||
function fixture(options: {
|
function fixture(options: {
|
||||||
loaded?: LoadedAuthConfig;
|
loaded?: LoadedAuthConfig;
|
||||||
identity?: Awaited<ReturnType<OidcProtocol["callback"]>>;
|
identity?: Awaited<ReturnType<OidcProtocol["callback"]>>;
|
||||||
|
protocol?: OidcProtocol;
|
||||||
callbackFailure?: boolean;
|
callbackFailure?: boolean;
|
||||||
stateReturnTo?: string;
|
stateReturnTo?: string;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
@@ -106,7 +107,7 @@ function fixture(options: {
|
|||||||
const createTimes: Array<Date | undefined> = [];
|
const createTimes: Array<Date | undefined> = [];
|
||||||
const callbacks: URL[] = [];
|
const callbacks: URL[] = [];
|
||||||
let authorizationRequests = 0;
|
let authorizationRequests = 0;
|
||||||
const protocol: OidcProtocol = {
|
const defaultProtocol: OidcProtocol = {
|
||||||
authorizationUrl: async ({ state: received, nonce: receivedNonce, codeVerifier }) => {
|
authorizationUrl: async ({ state: received, nonce: receivedNonce, codeVerifier }) => {
|
||||||
authorizationRequests += 1;
|
authorizationRequests += 1;
|
||||||
expect(received).toBe(state);
|
expect(received).toBe(state);
|
||||||
@@ -124,6 +125,7 @@ function fixture(options: {
|
|||||||
},
|
},
|
||||||
diagnose: async () => undefined,
|
diagnose: async () => undefined,
|
||||||
};
|
};
|
||||||
|
const protocol = options.protocol ?? defaultProtocol;
|
||||||
const store = {
|
const store = {
|
||||||
createOidcState: async (input: Record<string, unknown>) => {
|
createOidcState: async (input: Record<string, unknown>) => {
|
||||||
stateInputs.push(input);
|
stateInputs.push(input);
|
||||||
@@ -198,6 +200,16 @@ async function finishOidcLogin(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function completesWithin<T>(operation: Promise<T>, timeoutMs = 150): Promise<T | undefined> {
|
||||||
|
return await new Promise((resolve) => {
|
||||||
|
const timeout = setTimeout(() => resolve(undefined), timeoutMs);
|
||||||
|
operation.then(
|
||||||
|
(value) => { clearTimeout(timeout); resolve(value); },
|
||||||
|
() => { clearTimeout(timeout); resolve(undefined); },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test("rate limits OIDC initiation before state creation and provider discovery", async () => {
|
test("rate limits OIDC initiation before state creation and provider discovery", async () => {
|
||||||
const subject = fixture();
|
const subject = fixture();
|
||||||
|
|
||||||
@@ -377,11 +389,66 @@ test("consumes state on callback failure and refuses replay", async () => {
|
|||||||
const failed = await finishOidcLogin(subject, cookie);
|
const failed = await finishOidcLogin(subject, cookie);
|
||||||
expect(failed.statusCode).toBe(401);
|
expect(failed.statusCode).toBe(401);
|
||||||
expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
|
expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
|
||||||
|
expect(subject.creates).toEqual([]);
|
||||||
|
const failedOutput = JSON.stringify({ body: failed.json(), cookies: setCookieHeaders(failed) });
|
||||||
|
expect(failedOutput).not.toContain("access-token-must-not-leak");
|
||||||
|
expect(failedOutput).not.toContain("opaque-session-token");
|
||||||
const replay = await finishOidcLogin(subject, cookie);
|
const replay = await finishOidcLogin(subject, cookie);
|
||||||
expect(replay.statusCode).toBe(401);
|
expect(replay.statusCode).toBe(401);
|
||||||
expect(subject.callbacks).toHaveLength(1);
|
expect(subject.callbacks).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("fails a bounded concrete-provider callback without a session, cookie, or token persistence", async () => {
|
||||||
|
let tokenAborted = false;
|
||||||
|
const protocol = createOidcProtocol({
|
||||||
|
issuer,
|
||||||
|
clientId: "thothii",
|
||||||
|
clientSecret: "client-secret-must-not-persist",
|
||||||
|
callbackUrl: "https://thothii.example.test/api/auth/oidc/callback",
|
||||||
|
scopes: ["openid", "profile"],
|
||||||
|
groupsClaim: "groups",
|
||||||
|
httpTimeoutMs: 20,
|
||||||
|
fetch: async (input, init) => {
|
||||||
|
const url = new URL(input instanceof Request ? input.url : input.toString());
|
||||||
|
if (url.pathname.includes(".well-known/")) {
|
||||||
|
return Response.json({
|
||||||
|
issuer,
|
||||||
|
authorization_endpoint: `${issuer}/authorize`,
|
||||||
|
token_endpoint: `${issuer}/token`,
|
||||||
|
jwks_uri: `${issuer}/jwks`,
|
||||||
|
response_types_supported: ["code"],
|
||||||
|
grant_types_supported: ["authorization_code"],
|
||||||
|
subject_types_supported: ["public"],
|
||||||
|
id_token_signing_alg_values_supported: ["RS256"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.pathname === "/token") {
|
||||||
|
return await new Promise<Response>((_resolve, reject) => {
|
||||||
|
init?.signal?.addEventListener("abort", () => {
|
||||||
|
tokenAborted = true;
|
||||||
|
reject(new Error("provider access-token-must-not-persist"));
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(null, { status: 404 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const subject = fixture({ protocol });
|
||||||
|
const { cookie } = await beginOidcLogin(subject);
|
||||||
|
expect(cookie).toBeDefined();
|
||||||
|
|
||||||
|
const failed = await completesWithin(finishOidcLogin(subject, cookie));
|
||||||
|
|
||||||
|
expect(failed?.statusCode).toBe(401);
|
||||||
|
expect(tokenAborted).toBe(true);
|
||||||
|
expect(subject.creates).toEqual([]);
|
||||||
|
if (!failed) return;
|
||||||
|
expectBothTransactionVariantsCleared(failed, transactionCookieName, true);
|
||||||
|
const failedOutput = JSON.stringify({ body: failed.json(), cookies: setCookieHeaders(failed) });
|
||||||
|
expect(failedOutput).not.toContain("access-token-must-not-persist");
|
||||||
|
expect(failedOutput).not.toContain("thothii_session");
|
||||||
|
});
|
||||||
|
|
||||||
test("consumes state when the protocol becomes unavailable before callback", async () => {
|
test("consumes state when the protocol becomes unavailable before callback", async () => {
|
||||||
const subject = fixture();
|
const subject = fixture();
|
||||||
const { cookie } = await beginOidcLogin(subject);
|
const { cookie } = await beginOidcLogin(subject);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createSign, generateKeyPairSync } from "node:crypto";
|
import { createSign, generateKeyPairSync } from "node:crypto";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { createOidcProtocol, OidcProtocolError } from "../src/auth/oidc-client.js";
|
import { createOidcProtocol, OidcProtocolError, OidcProviderUnavailableError } from "../src/auth/oidc-client.js";
|
||||||
|
|
||||||
const issuer = "https://issuer.example.test";
|
const issuer = "https://issuer.example.test";
|
||||||
const clientId = "thothii";
|
const clientId = "thothii";
|
||||||
@@ -28,7 +28,10 @@ function protocol(options: {
|
|||||||
discoveryIssuer?: string;
|
discoveryIssuer?: string;
|
||||||
invalidSignature?: boolean;
|
invalidSignature?: boolean;
|
||||||
seen?: URL[];
|
seen?: URL[];
|
||||||
|
discoveryResponse?: (init?: RequestInit) => Response | Promise<Response>;
|
||||||
|
tokenResponse?: (init?: RequestInit) => Response | Promise<Response>;
|
||||||
jwksResponse?: (init?: RequestInit) => Response | Promise<Response>;
|
jwksResponse?: (init?: RequestInit) => Response | Promise<Response>;
|
||||||
|
httpTimeoutMs?: number;
|
||||||
jwksTimeoutMs?: number;
|
jwksTimeoutMs?: number;
|
||||||
discoveryMetadata?: Record<string, unknown>;
|
discoveryMetadata?: Record<string, unknown>;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
@@ -48,6 +51,7 @@ function protocol(options: {
|
|||||||
const url = new URL(input instanceof Request ? input.url : typeof input === "string" ? input : input.toString());
|
const url = new URL(input instanceof Request ? input.url : typeof input === "string" ? input : input.toString());
|
||||||
options.seen?.push(url);
|
options.seen?.push(url);
|
||||||
if (url.pathname.includes(".well-known/")) {
|
if (url.pathname.includes(".well-known/")) {
|
||||||
|
if (options.discoveryResponse) return await options.discoveryResponse(init);
|
||||||
return Response.json({
|
return Response.json({
|
||||||
issuer: options.discoveryIssuer ?? issuer,
|
issuer: options.discoveryIssuer ?? issuer,
|
||||||
authorization_endpoint: `${issuer}/authorize`,
|
authorization_endpoint: `${issuer}/authorize`,
|
||||||
@@ -62,6 +66,7 @@ function protocol(options: {
|
|||||||
}
|
}
|
||||||
if (url.pathname === "/jwks") return options.jwksResponse ? await options.jwksResponse(init) : Response.json({ keys: [jwk] });
|
if (url.pathname === "/jwks") return options.jwksResponse ? await options.jwksResponse(init) : Response.json({ keys: [jwk] });
|
||||||
if (url.pathname === "/token") {
|
if (url.pathname === "/token") {
|
||||||
|
if (options.tokenResponse) return await options.tokenResponse(init);
|
||||||
return Response.json({
|
return Response.json({
|
||||||
token_type: "Bearer",
|
token_type: "Bearer",
|
||||||
access_token: "access-token-must-not-be-persisted",
|
access_token: "access-token-must-not-be-persisted",
|
||||||
@@ -79,6 +84,7 @@ function protocol(options: {
|
|||||||
scopes: ["openid", "profile"],
|
scopes: ["openid", "profile"],
|
||||||
groupsClaim: "groups",
|
groupsClaim: "groups",
|
||||||
fetch,
|
fetch,
|
||||||
|
...(options.httpTimeoutMs === undefined ? {} : { httpTimeoutMs: options.httpTimeoutMs }),
|
||||||
...(options.jwksTimeoutMs === undefined ? {} : { jwksTimeoutMs: options.jwksTimeoutMs }),
|
...(options.jwksTimeoutMs === undefined ? {} : { jwksTimeoutMs: options.jwksTimeoutMs }),
|
||||||
} as Parameters<typeof createOidcProtocol>[0];
|
} as Parameters<typeof createOidcProtocol>[0];
|
||||||
return createOidcProtocol(protocolOptions);
|
return createOidcProtocol(protocolOptions);
|
||||||
@@ -90,6 +96,16 @@ async function callback(subject = protocol()) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function settlesWithin(operation: Promise<unknown>, timeoutMs = 150): Promise<"resolved" | "rejected" | "timed-out"> {
|
||||||
|
return await new Promise((resolve) => {
|
||||||
|
const timeout = setTimeout(() => resolve("timed-out"), timeoutMs);
|
||||||
|
operation.then(
|
||||||
|
() => { clearTimeout(timeout); resolve("resolved"); },
|
||||||
|
() => { clearTimeout(timeout); resolve("rejected"); },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test("uses HTTPS discovery, Authorization Code, and PKCE S256 without external network", async () => {
|
test("uses HTTPS discovery, Authorization Code, and PKCE S256 without external network", async () => {
|
||||||
const seen: URL[] = [];
|
const seen: URL[] = [];
|
||||||
const subject = protocol({ seen });
|
const subject = protocol({ seen });
|
||||||
@@ -109,6 +125,160 @@ test("uses HTTPS discovery, Authorization Code, and PKCE S256 without external n
|
|||||||
expect(seen.map((url) => url.origin)).toEqual([issuer, issuer, issuer]);
|
expect(seen.map((url) => url.origin)).toEqual([issuer, issuer, issuer]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("rejects a hanging discovery request at the provider transport deadline", async () => {
|
||||||
|
let aborted = false;
|
||||||
|
const subject = protocol({
|
||||||
|
httpTimeoutMs: 20,
|
||||||
|
discoveryResponse: (init) => new Promise<Response>((_resolve, reject) => {
|
||||||
|
init?.signal?.addEventListener("abort", () => {
|
||||||
|
aborted = true;
|
||||||
|
reject(new DOMException("aborted", "AbortError"));
|
||||||
|
}, { once: true });
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const completion = subject.authorizationUrl({ state, nonce, codeVerifier: verifier });
|
||||||
|
expect(await settlesWithin(completion)).toBe("rejected");
|
||||||
|
const error = await completion.catch((reason: unknown) => reason);
|
||||||
|
expect(error).toBeInstanceOf(OidcProviderUnavailableError);
|
||||||
|
expect((error as Error).message).toBe("oidc_provider_unavailable");
|
||||||
|
expect(aborted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects oversized discovery Content-Length before reading or buffering its body", async () => {
|
||||||
|
let pulls = 0;
|
||||||
|
let cancelled = false;
|
||||||
|
const body = new ReadableStream({
|
||||||
|
type: "bytes",
|
||||||
|
pull(controller) {
|
||||||
|
pulls += 1;
|
||||||
|
controller.enqueue(new TextEncoder().encode("{}"));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
cancel() { cancelled = true; },
|
||||||
|
});
|
||||||
|
const subject = protocol({
|
||||||
|
discoveryResponse: () => new Response(body, { headers: { "content-length": String(1024 * 1024 + 1) } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(subject.authorizationUrl({ state, nonce, codeVerifier: verifier })).rejects.toThrow(OidcProtocolError);
|
||||||
|
expect(pulls).toBe(0);
|
||||||
|
expect(cancelled).toBe(true);
|
||||||
|
expect(body.locked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stops chunked discovery streaming at the provider response limit", async () => {
|
||||||
|
let pulls = 0;
|
||||||
|
let cancelled = false;
|
||||||
|
const body = new ReadableStream({
|
||||||
|
type: "bytes",
|
||||||
|
pull(controller) {
|
||||||
|
pulls += 1;
|
||||||
|
if (pulls === 1) controller.enqueue(new Uint8Array(700_000));
|
||||||
|
else if (pulls === 2) controller.enqueue(new Uint8Array(400_000));
|
||||||
|
else {
|
||||||
|
controller.enqueue(new Uint8Array([1]));
|
||||||
|
controller.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancel() { cancelled = true; },
|
||||||
|
});
|
||||||
|
const subject = protocol({ discoveryResponse: () => new Response(body) });
|
||||||
|
|
||||||
|
await expect(subject.authorizationUrl({ state, nonce, codeVerifier: verifier })).rejects.toThrow(OidcProtocolError);
|
||||||
|
expect(pulls).toBe(2);
|
||||||
|
expect(cancelled).toBe(true);
|
||||||
|
expect(body.locked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a hanging token exchange at the provider transport deadline", async () => {
|
||||||
|
let aborted = false;
|
||||||
|
const subject = protocol({
|
||||||
|
httpTimeoutMs: 20,
|
||||||
|
tokenResponse: (init) => new Promise<Response>((_resolve, reject) => {
|
||||||
|
init?.signal?.addEventListener("abort", () => {
|
||||||
|
aborted = true;
|
||||||
|
reject(new DOMException("aborted", "AbortError"));
|
||||||
|
}, { once: true });
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await subject.authorizationUrl({ state, nonce, codeVerifier: verifier });
|
||||||
|
|
||||||
|
const completion = callback(subject);
|
||||||
|
expect(await settlesWithin(completion)).toBe("rejected");
|
||||||
|
const error = await completion.catch((reason: unknown) => reason);
|
||||||
|
expect(error).toBeInstanceOf(OidcProviderUnavailableError);
|
||||||
|
expect((error as Error).message).toBe("oidc_provider_unavailable");
|
||||||
|
expect(aborted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects oversized token Content-Length before reading or buffering its body", async () => {
|
||||||
|
let pulls = 0;
|
||||||
|
let cancelled = false;
|
||||||
|
const body = new ReadableStream({
|
||||||
|
type: "bytes",
|
||||||
|
pull(controller) {
|
||||||
|
pulls += 1;
|
||||||
|
controller.enqueue(new TextEncoder().encode("{}"));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
cancel() { cancelled = true; },
|
||||||
|
});
|
||||||
|
const subject = protocol({
|
||||||
|
tokenResponse: () => new Response(body, { headers: { "content-length": String(1024 * 1024 + 1) } }),
|
||||||
|
});
|
||||||
|
await subject.authorizationUrl({ state, nonce, codeVerifier: verifier });
|
||||||
|
|
||||||
|
await expect(callback(subject)).rejects.toThrow(OidcProtocolError);
|
||||||
|
expect(pulls).toBe(0);
|
||||||
|
expect(cancelled).toBe(true);
|
||||||
|
expect(body.locked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stops chunked token streaming at the provider response limit", async () => {
|
||||||
|
let pulls = 0;
|
||||||
|
let cancelled = false;
|
||||||
|
const body = new ReadableStream({
|
||||||
|
type: "bytes",
|
||||||
|
pull(controller) {
|
||||||
|
pulls += 1;
|
||||||
|
if (pulls === 1) controller.enqueue(new Uint8Array(700_000));
|
||||||
|
else if (pulls === 2) controller.enqueue(new Uint8Array(400_000));
|
||||||
|
else {
|
||||||
|
controller.enqueue(new Uint8Array([1]));
|
||||||
|
controller.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancel() { cancelled = true; },
|
||||||
|
});
|
||||||
|
const subject = protocol({ tokenResponse: () => new Response(body) });
|
||||||
|
await subject.authorizationUrl({ state, nonce, codeVerifier: verifier });
|
||||||
|
|
||||||
|
await expect(callback(subject)).rejects.toThrow(OidcProtocolError);
|
||||||
|
expect(pulls).toBe(2);
|
||||||
|
expect(cancelled).toBe(true);
|
||||||
|
expect(body.locked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails promptly and releases a provider response whose cancellation never settles", async () => {
|
||||||
|
let cancelled = false;
|
||||||
|
const body = new ReadableStream({
|
||||||
|
pull() { return new Promise<void>(() => { /* no body bytes are ever delivered */ }); },
|
||||||
|
cancel() {
|
||||||
|
cancelled = true;
|
||||||
|
return new Promise<void>(() => { /* cancellation remains advisory */ });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const subject = protocol({
|
||||||
|
discoveryResponse: () => new Response(body, { headers: { "content-length": String(1024 * 1024 + 1) } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const completion = subject.authorizationUrl({ state, nonce, codeVerifier: verifier });
|
||||||
|
expect(await settlesWithin(completion)).toBe("rejected");
|
||||||
|
expect(cancelled).toBe(true);
|
||||||
|
expect(body.locked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test("rejects non-HTTPS issuer configuration and a discovery issuer mismatch", async () => {
|
test("rejects non-HTTPS issuer configuration and a discovery issuer mismatch", async () => {
|
||||||
expect(() => createOidcProtocol({
|
expect(() => createOidcProtocol({
|
||||||
issuer: "http://issuer.example.test", clientId, clientSecret: "secret", callbackUrl,
|
issuer: "http://issuer.example.test", clientId, clientSecret: "secret", callbackUrl,
|
||||||
|
|||||||
Reference in New Issue
Block a user