fix(auth): bound all OIDC provider exchanges

This commit is contained in:
2026-08-17 10:03:07 +02:00
parent c86f01e886
commit 33ae7cfdc2
3 changed files with 437 additions and 59 deletions
+69 -2
View File
@@ -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);