fix(auth): bound OIDC initiation and transport

This commit is contained in:
2026-08-17 07:03:19 +02:00
parent 8573500121
commit bdabecbb63
15 changed files with 747 additions and 111 deletions
+24 -22
View File
@@ -7,6 +7,7 @@ import {
type Configuration,
} from "openid-client";
import { constants, createPublicKey, verify as verifySignature } from "node:crypto";
import { parseConfiguredTransportUrl } from "./url-policy.js";
export interface OidcIdentity {
issuer: string;
@@ -49,14 +50,15 @@ 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 configuredUrl(value: string): URL {
let url: URL;
try {
url = new URL(value);
} catch {
throw new OidcProtocolError();
}
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) throw new OidcProtocolError();
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;
}
@@ -180,29 +182,30 @@ async function readWithAbort(
}
async function boundedJwksBody(response: Response, signal: AbortSignal): Promise<string> {
const declaredLength = response.headers.get("content-length");
if (declaredLength !== null) {
if (!/^\d+$/.test(declaredLength)) throw new OidcProtocolError();
const length = Number(declaredLength);
if (!Number.isSafeInteger(length) || length > MAX_JWKS_BYTES) throw new OidcProtocolError();
}
if (!response.body) throw new OidcProtocolError();
const reader = response.body.getReader();
const chunks: Buffer[] = [];
let total = 0;
let completed = false;
try {
if (!response.ok) throw new OidcProtocolError();
const declaredLength = response.headers.get("content-length");
if (declaredLength !== null) {
if (!/^\d+$/.test(declaredLength)) throw new OidcProtocolError();
const length = Number(declaredLength);
if (!Number.isSafeInteger(length) || length > MAX_JWKS_BYTES) throw new OidcProtocolError();
}
while (true) {
const { done, value } = await readWithAbort(reader, signal);
if (done) break;
if (value.byteLength > MAX_JWKS_BYTES - total) {
await reader.cancel().catch(() => undefined);
throw new OidcProtocolError();
}
if (value.byteLength > MAX_JWKS_BYTES - total) throw new OidcProtocolError();
total += value.byteLength;
chunks.push(Buffer.from(value));
}
completed = true;
} finally {
reader.releaseLock();
if (!completed) await reader.cancel().catch(() => undefined);
try { reader.releaseLock(); } catch { /* cancellation already made the response unusable */ }
}
signal.throwIfAborted();
try {
@@ -235,7 +238,6 @@ async function verifyIdTokenSignature(
response = await (options.fetch ?? globalThis.fetch)(jwksUrl, {
headers: { accept: "application/json" }, redirect: "error", signal: controller.signal,
});
if (!response.ok) throw new OidcProtocolError();
const body = await boundedJwksBody(response, controller.signal);
const parsed = JSON.parse(body) as { keys?: unknown };
if (!Array.isArray(parsed.keys) || parsed.keys.length === 0 || parsed.keys.length > 16) throw new OidcProtocolError();
@@ -268,8 +270,8 @@ async function verifyIdTokenSignature(
}
export function createOidcProtocol(options: OidcProtocolOptions): OidcProtocol {
const issuerUrl = configuredUrl(options.issuer);
const callbackUrl = configuredUrl(options.callbackUrl);
const issuerUrl = configuredHttpsUrl(options.issuer);
const callbackUrl = configuredCallbackUrl(options.callbackUrl);
if (!text(options.clientId, 512) || !text(options.clientSecret, 4096)
|| !text(options.groupsClaim, 128) || options.scopes.length === 0 || options.scopes.length > 16
|| options.scopes.some((scope) => !text(scope, 128))