fix(auth): bound OIDC initiation and transport
This commit is contained in:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user