fix(auth): bound OIDC initiation and transport

This commit is contained in:
2026-08-17 07:03:19 +02:00
parent 8573500121
commit bdabecbb63
15 changed files with 747 additions and 111 deletions
+54 -1
View File
@@ -1,6 +1,6 @@
import Fastify from "fastify";
import cookie from "@fastify/cookie";
import { afterEach, expect, test } from "vitest";
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";
@@ -76,8 +76,10 @@ function fixture(options: {
const creates: Array<Record<string, unknown>> = [];
const createTimes: Array<Date | undefined> = [];
const callbacks: URL[] = [];
let authorizationRequests = 0;
const protocol: OidcProtocol = {
authorizationUrl: async ({ state: received, nonce: receivedNonce, codeVerifier }) => {
authorizationRequests += 1;
expect(received).toBe(state);
expect(receivedNonce).toHaveLength(43);
expect(codeVerifier).toHaveLength(43);
@@ -134,6 +136,7 @@ function fixture(options: {
createdApps.push(app);
return {
app, creates, createTimes, callbacks, stateInputs,
authorizationRequests: () => authorizationRequests,
setConfig(next: LoadedAuthConfig) { loaded = next; },
setProtocolAvailable(available: boolean) { protocolAvailable = available; },
stateWasConsumed: () => storedState === undefined,
@@ -161,6 +164,42 @@ async function finishOidcLogin(
});
}
test("rate limits OIDC initiation before state creation and provider discovery", async () => {
const subject = fixture();
for (let attempt = 0; attempt < 20; attempt += 1) {
expect((await beginOidcLogin(subject)).response.statusCode).toBe(302);
}
const limited = await beginOidcLogin(subject);
expect(limited.response.statusCode).toBe(429);
expect(limited.response.json()).toEqual({
code: "login_rate_limited",
error: "Too many login attempts",
});
expect(subject.stateInputs).toHaveLength(20);
expect(subject.authorizationRequests()).toBe(20);
});
test("OIDC initiation rate-limit capacity expires after ten minutes", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2030-01-02T03:04:05.000Z"));
const subject = fixture();
try {
for (let attempt = 0; attempt < 20; attempt += 1) {
expect((await beginOidcLogin(subject)).response.statusCode).toBe(302);
}
expect((await beginOidcLogin(subject)).response.statusCode).toBe(429);
vi.advanceTimersByTime(10 * 60_000 + 1);
expect((await beginOidcLogin(subject)).response.statusCode).toBe(302);
expect(subject.authorizationRequests()).toBe(21);
} finally {
vi.useRealTimers();
}
});
test("creates digest-only bound state, maps exact groups, creates a cookie session, and redirects safely", async () => {
const subject = fixture();
const { response: start, cookie } = await beginOidcLogin(subject);
@@ -298,6 +337,20 @@ test.each([
expect(subject.callbacks).toEqual([]);
});
test("boundedly burns a canonical state from an oversized callback URL", async () => {
const subject = fixture();
const { cookie } = await beginOidcLogin(subject);
const oversized = `state=${state}&code=good&padding=${"x".repeat(4096)}`;
const failed = await finishOidcLogin(subject, cookie, oversized);
expect(failed.statusCode).toBe(401);
expectTransactionCleared(failed);
expect(subject.stateWasConsumed()).toBe(true);
expect(subject.callbacks).toEqual([]);
expect((await finishOidcLogin(subject, cookie)).statusCode).toBe(401);
});
test("rejects an empty direct groups claim without creating a session cookie", async () => {
const subject = fixture({ identity: {
issuer, subject: "user-123", groups: [], tokenExpiresAt: new Date(Date.now() + 60_000),