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
+22 -3
View File
@@ -122,9 +122,28 @@ test.each([
expect(() => loadAuthenticationConfig(writeFixture(value))).toThrow("authentication configuration is invalid");
});
test("accepts the explicit loopback HTTP OIDC exception", () => {
expect(loadAuthenticationConfig(writeFixture(oidcConfig({ publicUrl: "http://127.0.0.1:8787" }))).value.mode)
.toBe("oidc");
test.each([
"http://127.0.0.1:8787",
"http://127.255.255.254:8787",
"http://[::1]:8787",
])("accepts the literal loopback HTTP OIDC exception %s", (publicUrl) => {
expect(loadAuthenticationConfig(writeFixture(oidcConfig({ publicUrl }))).value.mode).toBe("oidc");
});
test.each([
"http://localhost:8787",
"http://loopback.example.test:8787",
"http://user@127.0.0.1:8787",
"http://127.1:8787",
"http://127.0.0.01:8787",
"http://0177.0.0.1:8787",
"http://0x7f000001:8787",
"http://2130706433:8787",
"http://[::ffff:127.0.0.1]:8787",
"http://128.0.0.1:8787",
])("rejects non-canonical or non-loopback HTTP public URL %s", (publicUrl) => {
expect(() => loadAuthenticationConfig(writeFixture(oidcConfig({ publicUrl }))))
.toThrow("authentication configuration is invalid");
});
test("rejects unknown roles and requires exactly one admin group", () => {
+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),
+93 -2
View File
@@ -8,6 +8,7 @@ import {
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
realpathSync,
renameSync,
rmSync,
@@ -60,6 +61,7 @@ import {
createFileAuthSessionStore,
deriveCsrfToken,
type AuthSessionStore,
type FileAuthSessionStoreOptions,
type SessionCreateInput,
} from "../src/auth/session-store.js";
import { createWindowsAuthStorageBridge } from "../src/auth/windows-auth-storage.js";
@@ -104,11 +106,11 @@ function claimPath(rootPath: string, rawState: string): string {
return join(rootPath, "oidc", `${createHash("sha256").update(rawState).digest("hex")}.claim`);
}
function validStore(storageRoot: string): AuthSessionStore {
function validStore(storageRoot: string, options: FileAuthSessionStoreOptions = {}): AuthSessionStore {
return createFileAuthSessionStore(storageRoot, {
currentAuthConfigRevision: () => revision,
findLocalUser: async () => validLocalUser,
});
}, options);
}
async function create(
@@ -180,6 +182,52 @@ async function isolatedOidcConsumer(storageRoot: string, state: string): Promise
return { start: () => child.send("consume"), result };
}
async function isolatedOidcCreator(storageRoot: string, attempts: number): Promise<{
start(): void;
result: Promise<number>;
}> {
const child = fork(new URL("./fixtures/oidc-state-creator.mts", import.meta.url), [], {
cwd: process.cwd(),
execArgv: ["--import", "tsx"],
env: {
...process.env,
THT_TEST_SESSION_ROOT: storageRoot,
THT_TEST_OIDC_ATTEMPTS: String(attempts),
THT_TEST_OIDC_NOW: base.toISOString(),
THT_TEST_OIDC_CAPACITY: "8",
},
silent: true,
});
const ready = new Promise<void>((resolve, reject) => {
child.once("message", (message) => {
if (message === "ready") resolve();
else reject(new Error("OIDC creator did not become ready"));
});
child.once("error", reject);
child.once("exit", (code) => {
if (code !== null && code !== 0) reject(new Error("OIDC creator exited before ready"));
});
});
const result = new Promise<number>((resolve, reject) => {
let outcome: { created: unknown; failed?: unknown; error?: unknown } | undefined;
child.on("message", (message) => {
if (message && typeof message === "object" && "created" in message) {
outcome = message as { created: unknown; failed?: unknown; error?: unknown };
}
});
child.on("error", reject);
child.on("exit", (code) => {
if (code !== 0 || outcome?.failed === true || typeof outcome?.created !== "number") {
reject(new Error(`OIDC creator failed: ${String(outcome?.error)}`));
} else {
resolve(outcome.created);
}
});
});
await ready;
return { start: () => child.send("create"), result };
}
describe("file-backed auth session store", () => {
test("fails closed and revokes a session when constructed without validity dependencies", async () => {
const storageRoot = root();
@@ -301,6 +349,49 @@ describe("file-backed auth session store", () => {
.resolves.toBeUndefined();
});
test("rejects an OIDC-state flood without deleting any valid live state", async () => {
const storageRoot = root();
const store = validStore(storageRoot, { oidcStateCapacity: 8 });
const created = await Promise.all(Array.from({ length: 8 }, (_unused, index) =>
store.createOidcState(oidcInput(`n${String(index).padStart(42, "0")}`, `v${String(index).padStart(42, "0")}`), base)));
await expect(store.createOidcState(oidcInput("x".repeat(43), "y".repeat(43)), base))
.rejects.toThrow("auth_oidc_state_capacity");
await expect(store.consumeOidcState(created[0].state, new Date(base.getTime() + 60_000)))
.resolves.toMatchObject({ nonce: "n" + "0".repeat(42) });
expect(created.slice(1).every(({ state }) => existsSync(digestPath(storageRoot, "oidc", state)))).toBe(true);
});
test("prunes expired OIDC states before admitting a new transaction at capacity", async () => {
const storageRoot = root();
const store = validStore(storageRoot, { oidcStateCapacity: 8 });
const expired = await Promise.all(Array.from({ length: 8 }, (_unused, index) =>
store.createOidcState(oidcInput(`n${String(index).padStart(42, "0")}`, `v${String(index).padStart(42, "0")}`), base)));
const admitted = await store.createOidcState(
oidcInput("x".repeat(43), "y".repeat(43)),
new Date(base.getTime() + 10 * 60_000),
);
expect(expired.every(({ state }) => !existsSync(digestPath(storageRoot, "oidc", state)))).toBe(true);
expect(existsSync(digestPath(storageRoot, "oidc", admitted.state))).toBe(true);
});
test("bounds OIDC state creation across concurrent Node processes", async () => {
const storageRoot = root();
const creators = await Promise.all([
isolatedOidcCreator(storageRoot, 6),
isolatedOidcCreator(storageRoot, 6),
]);
creators.forEach(({ start }) => start());
const winners = await Promise.all(creators.map(({ result }) => result));
expect(winners.reduce((sum, value) => sum + value, 0)).toBe(8);
const names = readdirSync(join(storageRoot, "oidc"));
expect(names.filter((name) => /^[a-f0-9]{64}\.json$/.test(name))).toHaveLength(8);
});
test("fails closed when an OIDC state already has an atomic filesystem claim", async () => {
const storageRoot = root();
const store = validStore(storageRoot);
+41
View File
@@ -0,0 +1,41 @@
import { createFileAuthSessionStore } from "../../src/auth/session-store.js";
const root = process.env.THT_TEST_SESSION_ROOT;
const attempts = Number(process.env.THT_TEST_OIDC_ATTEMPTS);
const now = new Date(process.env.THT_TEST_OIDC_NOW ?? "invalid");
const capacity = Number(process.env.THT_TEST_OIDC_CAPACITY);
if (!root || !Number.isSafeInteger(attempts) || attempts < 1 || Number.isNaN(now.getTime())
|| !Number.isSafeInteger(capacity) || capacity < 1) process.exit(2);
const store = createFileAuthSessionStore(root, undefined, { oidcStateCapacity: capacity });
process.send?.("ready");
process.once("message", async (message) => {
if (message !== "create") process.exit(3);
let created = 0;
try {
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
await store.createOidcState({
nonce: `n${String(process.pid).padStart(10, "0")}${String(attempt).padStart(32, "0")}`,
codeVerifier: `v${String(process.pid).padStart(10, "0")}${String(attempt).padStart(32, "0")}`,
returnTo: "/",
authConfigRevision: "a".repeat(64),
issuer: "https://issuer.example.test",
browserTransactionDigest: "b".repeat(64),
}, now);
created += 1;
} catch (error) {
if (!(error instanceof Error) || error.message !== "auth_oidc_state_capacity") throw error;
}
}
process.send?.({ created });
process.exit(0);
} catch (error) {
process.send?.({
created,
failed: true,
error: error instanceof Error ? error.message : "non-error failure",
});
process.exit(4);
}
});
+76
View File
@@ -30,6 +30,7 @@ function protocol(options: {
seen?: URL[];
jwksResponse?: (init?: RequestInit) => Response | Promise<Response>;
jwksTimeoutMs?: number;
discoveryMetadata?: Record<string, unknown>;
} = {}) {
const now = Math.floor(Date.now() / 1000);
const claims = {
@@ -56,6 +57,7 @@ function protocol(options: {
grant_types_supported: ["authorization_code"],
subject_types_supported: ["public"],
id_token_signing_alg_values_supported: ["RS256"],
...options.discoveryMetadata,
});
}
if (url.pathname === "/jwks") return options.jwksResponse ? await options.jwksResponse(init) : Response.json({ keys: [jwk] });
@@ -116,6 +118,47 @@ test("rejects non-HTTPS issuer configuration and a discovery issuer mismatch", a
.rejects.toThrow(OidcProtocolError);
});
test.each([
["authorization", { authorization_endpoint: "http://127.0.0.1/authorize" }],
["token", { token_endpoint: "http://127.0.0.1/token" }],
["JWKS", { jwks_uri: "http://127.0.0.1/jwks" }],
])("keeps the discovered %s endpoint HTTPS-only", async (_label, discoveryMetadata) => {
const subject = protocol({ discoveryMetadata });
await expect(subject.authorizationUrl({ state, nonce, codeVerifier: verifier }))
.rejects.toThrow(OidcProtocolError);
});
test.each([
"http://127.0.0.1:3000/api/auth/oidc/callback",
"http://127.255.255.254/api/auth/oidc/callback",
"http://[::1]:3000/api/auth/oidc/callback",
"https://thothii.example.test/api/auth/oidc/callback",
])("accepts the configured callback URL %s", (configuredCallbackUrl) => {
expect(() => createOidcProtocol({
issuer, clientId, clientSecret: "secret", callbackUrl: configuredCallbackUrl,
scopes: ["openid"], groupsClaim: "groups",
})).not.toThrow();
});
test.each([
"http://localhost/api/auth/oidc/callback",
"http://loopback.example.test/api/auth/oidc/callback",
"http://user@127.0.0.1/api/auth/oidc/callback",
"http://127.1/api/auth/oidc/callback",
"http://127.0.0.01/api/auth/oidc/callback",
"http://0177.0.0.1/api/auth/oidc/callback",
"http://0x7f000001/api/auth/oidc/callback",
"http://2130706433/api/auth/oidc/callback",
"http://[::ffff:127.0.0.1]/api/auth/oidc/callback",
"http://128.0.0.1/api/auth/oidc/callback",
"http://192.168.1.1/api/auth/oidc/callback",
])("rejects the non-canonical or non-loopback HTTP callback URL %s", (configuredCallbackUrl) => {
expect(() => createOidcProtocol({
issuer, clientId, clientSecret: "secret", callbackUrl: configuredCallbackUrl,
scopes: ["openid"], groupsClaim: "groups",
})).toThrow(OidcProtocolError);
});
test.each([
["state", new URL(`${callbackUrl}?code=good&state=wrong`), {}],
["nonce", new URL(`${callbackUrl}?code=good&state=${state}`), { nonce: "wrong" }],
@@ -151,6 +194,7 @@ test("aborts a hanging JWKS request at the configured timeout", async () => {
test("rejects an oversized JWKS Content-Length before reading the body", async () => {
let pulls = 0;
let cancelled = false;
const body = new ReadableStream({
type: "bytes",
pull(controller) {
@@ -158,12 +202,43 @@ test("rejects an oversized JWKS Content-Length before reading the body", async (
controller.enqueue(new TextEncoder().encode("{}"));
controller.close();
},
cancel() { cancelled = true; },
});
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);
expect(cancelled).toBe(true);
expect(body.locked).toBe(false);
});
test("cancels and releases a JWKS stream with an invalid Content-Length", async () => {
let cancelled = false;
const body = new ReadableStream({
pull() { /* remains pending until the response is rejected and cancelled */ },
cancel() { cancelled = true; },
});
const subject = protocol({
jwksResponse: () => new Response(body, { headers: { "content-length": "not-a-number" } }),
});
await expect(callback(subject)).rejects.toThrow(OidcProtocolError);
expect(cancelled).toBe(true);
expect(body.locked).toBe(false);
});
test("cancels and releases a non-success JWKS response body", async () => {
let cancelled = false;
const body = new ReadableStream({
pull() { /* remains pending until the response is rejected and cancelled */ },
cancel() { cancelled = true; },
});
const subject = protocol({ jwksResponse: () => new Response(body, { status: 503 }) });
await expect(callback(subject)).rejects.toThrow(OidcProtocolError);
expect(cancelled).toBe(true);
expect(body.locked).toBe(false);
});
test("stops streaming a JWKS response as soon as the byte limit is exceeded", async () => {
@@ -186,6 +261,7 @@ test("stops streaming a JWKS response as soon as the byte limit is exceeded", as
await expect(callback(subject)).rejects.toThrow(OidcProtocolError);
expect(pulls).toBe(2);
expect(cancelled).toBe(true);
expect(body.locked).toBe(false);
});
test.each([
+36
View File
@@ -80,6 +80,42 @@ function bridgeForChild(child: FakeBridgeChild) {
}
describe("Windows auth-storage bridge", () => {
test("permits reservation slots only for OIDC record operations", async () => {
const requests: Array<Record<string, unknown>> = [];
const bridge = createWindowsAuthStorageBridge({
thtExecutable: "C:\\tht.exe",
invoke: async ({ input }) => {
const request = JSON.parse(input.toString("utf8")) as Record<string, unknown>;
requests.push(request);
const operation = request.operation;
const body = operation === "create"
? { created: true }
: operation === "read"
? { found: true, contentBase64: Buffer.from("slot").toString("base64") }
: operation === "remove"
? { removed: true }
: { entries: [{ name: "slot-00.json", modifiedUnixMs: 1 }] };
return {
code: 0,
stdout: Buffer.from(`${JSON.stringify({ version: 1, ok: true, ...body })}\n`),
stderr: Buffer.alloc(0),
};
},
});
await expect(bridge.create("C:\\auth", "oidc", "slot-00.json", Buffer.from("slot"))).resolves.toBe(true);
await expect(bridge.read("C:\\auth", "oidc", "slot-00.json")).resolves.toEqual(Buffer.from("slot"));
await expect(bridge.list("C:\\auth", "oidc")).resolves.toEqual([
{ name: "slot-00.json", modifiedUnixMs: 1 },
]);
await expect(bridge.remove("C:\\auth", "oidc", "slot-00.json")).resolves.toBe(true);
await expect(bridge.create("C:\\auth", "sessions", "slot-00.json", Buffer.from("slot")))
.rejects.toThrow("auth_session_store_invalid");
await expect(bridge.create("C:\\auth", "oidc", "slot-64.json", Buffer.from("slot")))
.rejects.toThrow("auth_session_store_invalid");
expect(requests).toHaveLength(4);
});
test("uses hidden tht argv and sends record bytes only over bounded stdin", async () => {
const calls: Array<{ executable: string; args: readonly string[]; input: Buffer; timeoutMs: number }> = [];
const bridge = createWindowsAuthStorageBridge({