fix(auth): bound all OIDC provider exchanges
This commit is contained in:
@@ -4,7 +4,7 @@ import { afterEach, expect, test, vi } from "vitest";
|
||||
import { registerAuthRoutes } from "../src/auth/routes.js";
|
||||
import type { LoadedAuthConfig, OidcStateRecord } from "../src/auth/types.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 issuer = "https://issuer.example.test";
|
||||
@@ -95,6 +95,7 @@ function stateRecord(extra: Partial<OidcStateRecord> = {}): OidcStateRecord {
|
||||
function fixture(options: {
|
||||
loaded?: LoadedAuthConfig;
|
||||
identity?: Awaited<ReturnType<OidcProtocol["callback"]>>;
|
||||
protocol?: OidcProtocol;
|
||||
callbackFailure?: boolean;
|
||||
stateReturnTo?: string;
|
||||
} = {}) {
|
||||
@@ -106,7 +107,7 @@ function fixture(options: {
|
||||
const createTimes: Array<Date | undefined> = [];
|
||||
const callbacks: URL[] = [];
|
||||
let authorizationRequests = 0;
|
||||
const protocol: OidcProtocol = {
|
||||
const defaultProtocol: OidcProtocol = {
|
||||
authorizationUrl: async ({ state: received, nonce: receivedNonce, codeVerifier }) => {
|
||||
authorizationRequests += 1;
|
||||
expect(received).toBe(state);
|
||||
@@ -124,6 +125,7 @@ function fixture(options: {
|
||||
},
|
||||
diagnose: async () => undefined,
|
||||
};
|
||||
const protocol = options.protocol ?? defaultProtocol;
|
||||
const store = {
|
||||
createOidcState: async (input: Record<string, unknown>) => {
|
||||
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 () => {
|
||||
const subject = fixture();
|
||||
|
||||
@@ -377,11 +389,66 @@ test("consumes state on callback failure and refuses replay", async () => {
|
||||
const failed = await finishOidcLogin(subject, cookie);
|
||||
expect(failed.statusCode).toBe(401);
|
||||
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);
|
||||
expect(replay.statusCode).toBe(401);
|
||||
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 () => {
|
||||
const subject = fixture();
|
||||
const { cookie } = await beginOidcLogin(subject);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createSign, generateKeyPairSync } from "node:crypto";
|
||||
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 clientId = "thothii";
|
||||
@@ -28,7 +28,10 @@ function protocol(options: {
|
||||
discoveryIssuer?: string;
|
||||
invalidSignature?: boolean;
|
||||
seen?: URL[];
|
||||
discoveryResponse?: (init?: RequestInit) => Response | Promise<Response>;
|
||||
tokenResponse?: (init?: RequestInit) => Response | Promise<Response>;
|
||||
jwksResponse?: (init?: RequestInit) => Response | Promise<Response>;
|
||||
httpTimeoutMs?: number;
|
||||
jwksTimeoutMs?: number;
|
||||
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());
|
||||
options.seen?.push(url);
|
||||
if (url.pathname.includes(".well-known/")) {
|
||||
if (options.discoveryResponse) return await options.discoveryResponse(init);
|
||||
return Response.json({
|
||||
issuer: options.discoveryIssuer ?? issuer,
|
||||
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 === "/token") {
|
||||
if (options.tokenResponse) return await options.tokenResponse(init);
|
||||
return Response.json({
|
||||
token_type: "Bearer",
|
||||
access_token: "access-token-must-not-be-persisted",
|
||||
@@ -79,6 +84,7 @@ function protocol(options: {
|
||||
scopes: ["openid", "profile"],
|
||||
groupsClaim: "groups",
|
||||
fetch,
|
||||
...(options.httpTimeoutMs === undefined ? {} : { httpTimeoutMs: options.httpTimeoutMs }),
|
||||
...(options.jwksTimeoutMs === undefined ? {} : { jwksTimeoutMs: options.jwksTimeoutMs }),
|
||||
} as Parameters<typeof createOidcProtocol>[0];
|
||||
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 () => {
|
||||
const seen: URL[] = [];
|
||||
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]);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
expect(() => createOidcProtocol({
|
||||
issuer: "http://issuer.example.test", clientId, clientSecret: "secret", callbackUrl,
|
||||
|
||||
Reference in New Issue
Block a user