fix(auth): harden OIDC browser transactions

This commit is contained in:
2026-08-17 06:18:49 +02:00
parent 4fe51cbeb1
commit 8573500121
9 changed files with 384 additions and 59 deletions
+62 -5
View File
@@ -37,12 +37,15 @@ export interface OidcProtocolOptions {
scopes: readonly string[];
groupsClaim: string;
fetch?: typeof globalThis.fetch;
jwksTimeoutMs?: number;
}
const MAX_GROUPS = 128;
const MAX_GROUP_LENGTH = 256;
const MAX_ID_TOKEN_LENGTH = 16 * 1024;
const MAX_JWKS_BYTES = 1024 * 1024;
const DEFAULT_JWKS_TIMEOUT_MS = 5_000;
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);
@@ -75,7 +78,7 @@ function groupsFromClaims(claims: Record<string, unknown>, name: string): string
&& Object.prototype.hasOwnProperty.call(indirect, name))
|| claims.hasgroups === true) throw new OidcProtocolError();
const raw = claims[name];
if (!Array.isArray(raw) || raw.length > MAX_GROUPS) throw new OidcProtocolError();
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) {
@@ -161,6 +164,54 @@ function joseEcdsaSignatureToDer(signature: Buffer, partLength: number): Buffer
return Buffer.concat([Buffer.from([0x30]), derLength(sequence.length), sequence]);
}
async function readWithAbort(
reader: ReadableStreamDefaultReader<Uint8Array>,
signal: AbortSignal,
): Promise<ReadableStreamReadResult<Uint8Array>> {
signal.throwIfAborted();
return await new Promise((resolve, reject) => {
const aborted = () => {
void reader.cancel().catch(() => undefined);
reject(signal.reason);
};
signal.addEventListener("abort", aborted, { once: true });
reader.read().then(resolve, reject).finally(() => signal.removeEventListener("abort", aborted));
});
}
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;
try {
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();
}
total += value.byteLength;
chunks.push(Buffer.from(value));
}
} finally {
reader.releaseLock();
}
signal.throwIfAborted();
try {
return new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks, total));
} catch {
throw new OidcProtocolError();
}
}
async function verifyIdTokenSignature(
idToken: unknown,
config: Configuration,
@@ -176,14 +227,16 @@ async function verifyIdTokenSignature(
|| !metadata.id_token_signing_alg_values_supported.includes(header.alg)
|| !text(metadata.jwks_uri, 2048)) throw new OidcProtocolError();
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 {
response = await (options.fetch ?? globalThis.fetch)(jwksUrl, {
headers: { accept: "application/json" }, redirect: "error",
headers: { accept: "application/json" }, redirect: "error", signal: controller.signal,
});
if (!response.ok) throw new OidcProtocolError();
const body = await response.text();
if (Buffer.byteLength(body, "utf8") > MAX_JWKS_BYTES) 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();
const matching = parsed.keys.filter((key): key is Record<string, unknown> =>
@@ -209,6 +262,8 @@ async function verifyIdTokenSignature(
} catch (error) {
if (error instanceof OidcProtocolError) throw error;
throw new OidcProtocolError();
} finally {
clearTimeout(timeout);
}
}
@@ -217,7 +272,9 @@ export function createOidcProtocol(options: OidcProtocolOptions): OidcProtocol {
const callbackUrl = configuredUrl(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))) throw new OidcProtocolError();
|| options.scopes.some((scope) => !text(scope, 128))
|| (options.jwksTimeoutMs !== undefined && (!Number.isSafeInteger(options.jwksTimeoutMs)
|| options.jwksTimeoutMs < 1 || options.jwksTimeoutMs > MAX_JWKS_TIMEOUT_MS))) throw new OidcProtocolError();
let discovered: Promise<Configuration> | undefined;
const configuration = async (): Promise<Configuration> => {