fix(auth): harden OIDC browser transactions
This commit is contained in:
@@ -28,6 +28,8 @@ function protocol(options: {
|
||||
discoveryIssuer?: string;
|
||||
invalidSignature?: boolean;
|
||||
seen?: URL[];
|
||||
jwksResponse?: (init?: RequestInit) => Response | Promise<Response>;
|
||||
jwksTimeoutMs?: number;
|
||||
} = {}) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const claims = {
|
||||
@@ -41,7 +43,7 @@ function protocol(options: {
|
||||
groups: ["TOT Users", "Unmapped group"],
|
||||
...options.claims,
|
||||
};
|
||||
const fetch = async (input: RequestInfo | URL) => {
|
||||
const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = new URL(input instanceof Request ? input.url : typeof input === "string" ? input : input.toString());
|
||||
options.seen?.push(url);
|
||||
if (url.pathname.includes(".well-known/")) {
|
||||
@@ -56,7 +58,7 @@ function protocol(options: {
|
||||
id_token_signing_alg_values_supported: ["RS256"],
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/jwks") return Response.json({ keys: [jwk] });
|
||||
if (url.pathname === "/jwks") return options.jwksResponse ? await options.jwksResponse(init) : Response.json({ keys: [jwk] });
|
||||
if (url.pathname === "/token") {
|
||||
return Response.json({
|
||||
token_type: "Bearer",
|
||||
@@ -67,7 +69,7 @@ function protocol(options: {
|
||||
}
|
||||
return new Response(null, { status: 404 });
|
||||
};
|
||||
return createOidcProtocol({
|
||||
const protocolOptions = {
|
||||
issuer,
|
||||
clientId,
|
||||
clientSecret: "client-secret-must-not-be-persisted",
|
||||
@@ -75,7 +77,9 @@ function protocol(options: {
|
||||
scopes: ["openid", "profile"],
|
||||
groupsClaim: "groups",
|
||||
fetch,
|
||||
});
|
||||
...(options.jwksTimeoutMs === undefined ? {} : { jwksTimeoutMs: options.jwksTimeoutMs }),
|
||||
} as Parameters<typeof createOidcProtocol>[0];
|
||||
return createOidcProtocol(protocolOptions);
|
||||
}
|
||||
|
||||
async function callback(subject = protocol()) {
|
||||
@@ -128,9 +132,66 @@ test("rejects invalid ID-token signatures", async () => {
|
||||
await expect(callback(protocol({ invalidSignature: true }))).rejects.toThrow(OidcProtocolError);
|
||||
});
|
||||
|
||||
test("aborts a hanging JWKS request at the configured timeout", async () => {
|
||||
let aborted = false;
|
||||
const subject = protocol({
|
||||
jwksTimeoutMs: 20,
|
||||
jwksResponse: (init) => new Promise<Response>((_resolve, reject) => {
|
||||
const fallback = setTimeout(() => reject(new Error("JWKS fixture was not aborted")), 200);
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
aborted = true;
|
||||
clearTimeout(fallback);
|
||||
reject(new DOMException("aborted", "AbortError"));
|
||||
}, { once: true });
|
||||
}),
|
||||
});
|
||||
await expect(callback(subject)).rejects.toThrow(OidcProtocolError);
|
||||
expect(aborted).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects an oversized JWKS Content-Length before reading the body", async () => {
|
||||
let pulls = 0;
|
||||
const body = new ReadableStream({
|
||||
type: "bytes",
|
||||
pull(controller) {
|
||||
pulls += 1;
|
||||
controller.enqueue(new TextEncoder().encode("{}"));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const subject = protocol({
|
||||
jwksResponse: () => new Response(body, { headers: { "content-length": String(1024 * 1024 + 1) } }),
|
||||
});
|
||||
await expect(callback(subject)).rejects.toThrow(OidcProtocolError);
|
||||
expect(pulls).toBe(0);
|
||||
});
|
||||
|
||||
test("stops streaming a JWKS response as soon as the byte limit is exceeded", 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({ jwksResponse: () => new Response(body) });
|
||||
await expect(callback(subject)).rejects.toThrow(OidcProtocolError);
|
||||
expect(pulls).toBe(2);
|
||||
expect(cancelled).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["absent", { groups: undefined }],
|
||||
["non-array", { groups: "TOT Users" }],
|
||||
["empty array", { groups: [] }],
|
||||
["empty", { groups: [""] }],
|
||||
["duplicate", { groups: ["TOT Users", "TOT Users"] }],
|
||||
["control", { groups: ["TOT\u0000Users"] }],
|
||||
|
||||
Reference in New Issue
Block a user