fix(auth): harden OIDC browser transactions
This commit is contained in:
@@ -11,8 +11,25 @@ const issuer = "https://issuer.example.test";
|
||||
const state = "s".repeat(43);
|
||||
const nonce = "n".repeat(43);
|
||||
const verifier = "v".repeat(43);
|
||||
const transactionCookieName = "__Host-thothii_oidc_tx";
|
||||
const createdApps: Array<ReturnType<typeof Fastify>> = [];
|
||||
|
||||
function setCookieHeaders(response: { headers: Record<string, string | string[] | undefined> }): string[] {
|
||||
const header = response.headers["set-cookie"];
|
||||
return header === undefined ? [] : Array.isArray(header) ? header : [header];
|
||||
}
|
||||
|
||||
function transactionCookie(response: { headers: Record<string, string | string[] | undefined> }): string | undefined {
|
||||
return setCookieHeaders(response)
|
||||
.find((header) => header.startsWith(`${transactionCookieName}=`))
|
||||
?.split(";", 1)[0];
|
||||
}
|
||||
|
||||
function expectTransactionCleared(response: { headers: Record<string, string | string[] | undefined> }): void {
|
||||
expect(setCookieHeaders(response).some((header) =>
|
||||
header.startsWith(`${transactionCookieName}=`) && header.includes("Max-Age=0"))).toBe(true);
|
||||
}
|
||||
|
||||
function config(overrides: Partial<LoadedAuthConfig["value"]> = {}): LoadedAuthConfig {
|
||||
return {
|
||||
revision,
|
||||
@@ -40,6 +57,7 @@ function stateRecord(extra: Partial<OidcStateRecord> = {}): OidcStateRecord {
|
||||
return {
|
||||
version: 1, nonce, codeVerifier: verifier, returnTo: "/",
|
||||
authConfigRevision: revision, issuer,
|
||||
browserTransactionDigest: "b".repeat(64),
|
||||
createdAt: "2030-01-01T00:00:00.000Z", expiresAt: "2030-01-01T00:10:00.000Z",
|
||||
...extra,
|
||||
} as OidcStateRecord;
|
||||
@@ -56,6 +74,7 @@ function fixture(options: {
|
||||
let storedState: OidcStateRecord | undefined;
|
||||
const stateInputs: Array<Record<string, unknown>> = [];
|
||||
const creates: Array<Record<string, unknown>> = [];
|
||||
const createTimes: Array<Date | undefined> = [];
|
||||
const callbacks: URL[] = [];
|
||||
const protocol: OidcProtocol = {
|
||||
authorizationUrl: async ({ state: received, nonce: receivedNonce, codeVerifier }) => {
|
||||
@@ -83,6 +102,8 @@ function fixture(options: {
|
||||
authConfigRevision: input.authConfigRevision as string,
|
||||
issuer: input.issuer as string,
|
||||
});
|
||||
(record as OidcStateRecord & { browserTransactionDigest: string }).browserTransactionDigest =
|
||||
input.browserTransactionDigest as string;
|
||||
if (options.stateReturnTo) (record as { returnTo: string }).returnTo = options.stateReturnTo;
|
||||
storedState = record;
|
||||
return { state, record: storedState };
|
||||
@@ -93,8 +114,9 @@ function fixture(options: {
|
||||
storedState = undefined;
|
||||
return consumed;
|
||||
},
|
||||
create: async (input: Record<string, unknown>) => {
|
||||
create: async (input: Record<string, unknown>, now?: Date) => {
|
||||
creates.push(input);
|
||||
createTimes.push(now);
|
||||
return { token: "opaque-session-token", csrfToken: "c".repeat(43), record: {} };
|
||||
},
|
||||
} as unknown as AuthSessionStore;
|
||||
@@ -111,7 +133,7 @@ function fixture(options: {
|
||||
});
|
||||
createdApps.push(app);
|
||||
return {
|
||||
app, creates, callbacks, stateInputs,
|
||||
app, creates, createTimes, callbacks, stateInputs,
|
||||
setConfig(next: LoadedAuthConfig) { loaded = next; },
|
||||
setProtocolAvailable(available: boolean) { protocolAvailable = available; },
|
||||
stateWasConsumed: () => storedState === undefined,
|
||||
@@ -122,23 +144,50 @@ afterEach(async () => {
|
||||
await Promise.all(createdApps.splice(0).map((app) => app.close()));
|
||||
});
|
||||
|
||||
async function beginOidcLogin(subject: ReturnType<typeof fixture>) {
|
||||
const response = await subject.app.inject({ method: "GET", url: "/auth/oidc/login" });
|
||||
return { response, cookie: transactionCookie(response) };
|
||||
}
|
||||
|
||||
async function finishOidcLogin(
|
||||
subject: ReturnType<typeof fixture>,
|
||||
cookie: string | undefined,
|
||||
query = `code=good&state=${state}`,
|
||||
) {
|
||||
return await subject.app.inject({
|
||||
method: "GET",
|
||||
url: `/auth/oidc/callback?${query}`,
|
||||
...(cookie ? { headers: { cookie } } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
test("creates digest-only bound state, maps exact groups, creates a cookie session, and redirects safely", async () => {
|
||||
const subject = fixture();
|
||||
const start = await subject.app.inject({ method: "GET", url: "/auth/oidc/login" });
|
||||
const { response: start, cookie } = await beginOidcLogin(subject);
|
||||
expect(start.statusCode).toBe(302);
|
||||
expect(new URL(start.headers.location ?? "").searchParams.get("state")).toBe(state);
|
||||
expect(subject.stateInputs[0]).toMatchObject({ returnTo: "/", authConfigRevision: revision, issuer });
|
||||
expect(subject.stateInputs[0]).toMatchObject({
|
||||
returnTo: "/", authConfigRevision: revision, issuer,
|
||||
browserTransactionDigest: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
});
|
||||
expect(cookie).toMatch(new RegExp(`^${transactionCookieName}=[A-Za-z0-9_-]{43}$`));
|
||||
expect(setCookieHeaders(start).join("\n")).toContain("HttpOnly");
|
||||
expect(setCookieHeaders(start).join("\n")).toContain("SameSite=Lax");
|
||||
expect(setCookieHeaders(start).join("\n")).toContain("Secure");
|
||||
expect(setCookieHeaders(start).join("\n")).toContain("Path=/");
|
||||
expect(JSON.stringify(subject.stateInputs)).not.toContain(cookie?.split("=", 2)[1] ?? "missing-cookie");
|
||||
|
||||
const callback = await subject.app.inject({
|
||||
method: "GET",
|
||||
url: `/auth/oidc/callback?code=good&state=${state}`,
|
||||
headers: { host: "attacker.example.test" },
|
||||
headers: { host: "attacker.example.test", cookie: cookie ?? "" },
|
||||
});
|
||||
expect(callback.statusCode).toBe(302);
|
||||
expect(callback.headers.location).toBe("/");
|
||||
expect(callback.headers["set-cookie"]).toContain("HttpOnly");
|
||||
expect(callback.headers["set-cookie"]).toContain("SameSite=Lax");
|
||||
expect(callback.headers["set-cookie"]).toContain("Secure");
|
||||
expect(setCookieHeaders(callback).join("\n")).toContain("HttpOnly");
|
||||
expect(setCookieHeaders(callback).join("\n")).toContain("SameSite=Lax");
|
||||
expect(setCookieHeaders(callback).join("\n")).toContain("Secure");
|
||||
expectTransactionCleared(callback);
|
||||
expect(subject.callbacks[0]?.href).toBe(`https://thothii.example.test/api/auth/oidc/callback?code=good&state=${state}`);
|
||||
expect(subject.creates).toHaveLength(1);
|
||||
expect(subject.creates[0]).toMatchObject({
|
||||
@@ -156,27 +205,28 @@ test("creates digest-only bound state, maps exact groups, creates a cookie sessi
|
||||
|
||||
test("consumes state on callback failure and refuses replay", async () => {
|
||||
const subject = fixture({ callbackFailure: true });
|
||||
await subject.app.inject({ method: "GET", url: "/auth/oidc/login" });
|
||||
const failed = await subject.app.inject({ method: "GET", url: `/auth/oidc/callback?code=good&state=${state}` });
|
||||
const { cookie } = await beginOidcLogin(subject);
|
||||
const failed = await finishOidcLogin(subject, cookie);
|
||||
expect(failed.statusCode).toBe(401);
|
||||
const replay = await subject.app.inject({ method: "GET", url: `/auth/oidc/callback?code=good&state=${state}` });
|
||||
expectTransactionCleared(failed);
|
||||
const replay = await finishOidcLogin(subject, cookie);
|
||||
expect(replay.statusCode).toBe(401);
|
||||
expect(subject.callbacks).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("consumes state when the protocol becomes unavailable before callback", async () => {
|
||||
const subject = fixture();
|
||||
await subject.app.inject({ method: "GET", url: "/auth/oidc/login" });
|
||||
const { cookie } = await beginOidcLogin(subject);
|
||||
subject.setProtocolAvailable(false);
|
||||
const failed = await subject.app.inject({ method: "GET", url: `/auth/oidc/callback?code=good&state=${state}` });
|
||||
const failed = await finishOidcLogin(subject, cookie);
|
||||
expect(failed.statusCode).toBe(401);
|
||||
expect(subject.stateWasConsumed()).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects a consumed state with a non-root return target", async () => {
|
||||
const subject = fixture({ stateReturnTo: "https://attacker.example.test" });
|
||||
await subject.app.inject({ method: "GET", url: "/auth/oidc/login" });
|
||||
const callback = await subject.app.inject({ method: "GET", url: `/auth/oidc/callback?code=good&state=${state}` });
|
||||
const { cookie } = await beginOidcLogin(subject);
|
||||
const callback = await finishOidcLogin(subject, cookie);
|
||||
expect(callback.statusCode).toBe(401);
|
||||
expect(subject.callbacks).toEqual([]);
|
||||
expect(subject.creates).toEqual([]);
|
||||
@@ -184,22 +234,22 @@ test("rejects a consumed state with a non-root return target", async () => {
|
||||
|
||||
test("rejects an OIDC state when its configuration revision changes before callback", async () => {
|
||||
const subject = fixture();
|
||||
await subject.app.inject({ method: "GET", url: "/auth/oidc/login" });
|
||||
const { cookie } = await beginOidcLogin(subject);
|
||||
subject.setConfig({ ...config(), revision: "b".repeat(64) });
|
||||
const callback = await subject.app.inject({ method: "GET", url: `/auth/oidc/callback?code=good&state=${state}` });
|
||||
const callback = await finishOidcLogin(subject, cookie);
|
||||
expect(callback.statusCode).toBe(401);
|
||||
expect(subject.callbacks).toEqual([]);
|
||||
});
|
||||
|
||||
test("rejects an OIDC state when its issuer changes before callback", async () => {
|
||||
const subject = fixture();
|
||||
await subject.app.inject({ method: "GET", url: "/auth/oidc/login" });
|
||||
const { cookie } = await beginOidcLogin(subject);
|
||||
const previous = config();
|
||||
subject.setConfig({
|
||||
...previous,
|
||||
value: { ...previous.value, oidc: { ...previous.value.oidc, issuer: "https://other.example.test" } },
|
||||
} as LoadedAuthConfig);
|
||||
const callback = await subject.app.inject({ method: "GET", url: `/auth/oidc/callback?code=good&state=${state}` });
|
||||
const callback = await finishOidcLogin(subject, cookie);
|
||||
expect(callback.statusCode).toBe(401);
|
||||
expect(subject.callbacks).toEqual([]);
|
||||
});
|
||||
@@ -208,8 +258,67 @@ test("creates an authenticated but forbidden principal for extra unmapped groups
|
||||
const subject = fixture({ identity: {
|
||||
issuer, subject: "user-123", groups: ["Unmapped"], tokenExpiresAt: new Date(Date.now() + 60_000),
|
||||
} });
|
||||
await subject.app.inject({ method: "GET", url: "/auth/oidc/login" });
|
||||
const callback = await subject.app.inject({ method: "GET", url: `/auth/oidc/callback?code=good&state=${state}` });
|
||||
const { cookie } = await beginOidcLogin(subject);
|
||||
const callback = await finishOidcLogin(subject, cookie);
|
||||
expect(callback.statusCode).toBe(302);
|
||||
expect(subject.creates[0]).toMatchObject({ principal: { roles: [], permissions: [], isAdmin: false } });
|
||||
});
|
||||
|
||||
test("rejects a callback from a different browser and clears the transaction cookie", async () => {
|
||||
const subject = fixture();
|
||||
const { cookie } = await beginOidcLogin(subject);
|
||||
expect(cookie).toBeDefined();
|
||||
|
||||
const failed = await finishOidcLogin(subject, `${transactionCookieName}=${"x".repeat(43)}`);
|
||||
expect(failed.statusCode).toBe(401);
|
||||
expectTransactionCleared(failed);
|
||||
expect(subject.stateWasConsumed()).toBe(true);
|
||||
expect(subject.creates).toEqual([]);
|
||||
|
||||
const replay = await finishOidcLogin(subject, cookie);
|
||||
expect(replay.statusCode).toBe(401);
|
||||
expect(subject.creates).toEqual([]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["unknown", `code=good&state=${state}&unexpected=value`],
|
||||
["duplicate", `code=good&code=other&state=${state}`],
|
||||
["malformed", `code=good&state=${state}&error_description=%00bad`],
|
||||
])("burns canonical state before rejecting %s callback parameters", async (_label, query) => {
|
||||
const subject = fixture();
|
||||
const { cookie } = await beginOidcLogin(subject);
|
||||
const failed = await finishOidcLogin(subject, cookie, query);
|
||||
expect(failed.statusCode).toBe(401);
|
||||
expectTransactionCleared(failed);
|
||||
expect(subject.stateWasConsumed()).toBe(true);
|
||||
expect(subject.callbacks).toEqual([]);
|
||||
|
||||
const replay = await finishOidcLogin(subject, cookie);
|
||||
expect(replay.statusCode).toBe(401);
|
||||
expect(subject.callbacks).toEqual([]);
|
||||
});
|
||||
|
||||
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),
|
||||
} });
|
||||
const { cookie } = await beginOidcLogin(subject);
|
||||
const callback = await finishOidcLogin(subject, cookie);
|
||||
expect(callback.statusCode).toBe(401);
|
||||
expectTransactionCleared(callback);
|
||||
expect(subject.creates).toEqual([]);
|
||||
expect(setCookieHeaders(callback).join("\n")).not.toContain("opaque-session-token");
|
||||
});
|
||||
|
||||
test("uses one captured instant for token TTL derivation and session creation", async () => {
|
||||
const tokenExpiresAt = new Date(Date.now() + 120_000);
|
||||
const subject = fixture({ identity: {
|
||||
issuer, subject: "user-123", groups: ["Users"], tokenExpiresAt,
|
||||
} });
|
||||
const { cookie } = await beginOidcLogin(subject);
|
||||
const callback = await finishOidcLogin(subject, cookie);
|
||||
expect(callback.statusCode).toBe(302);
|
||||
expect(subject.createTimes[0]).toBeInstanceOf(Date);
|
||||
expect((subject.createTimes[0] as Date).getTime() + (subject.creates[0]?.absoluteTtlMs as number))
|
||||
.toBe(tokenExpiresAt.getTime());
|
||||
});
|
||||
|
||||
@@ -70,7 +70,14 @@ const revision = "a".repeat(64);
|
||||
const validLocalUser = { enabled: true, authRevision: 7, roles: ["admin"] as const };
|
||||
|
||||
function oidcInput(nonce: string, codeVerifier: string) {
|
||||
return { nonce, codeVerifier, returnTo: "/" as const, authConfigRevision: revision, issuer: "https://issuer.example.test" };
|
||||
return {
|
||||
nonce,
|
||||
codeVerifier,
|
||||
returnTo: "/" as const,
|
||||
authConfigRevision: revision,
|
||||
issuer: "https://issuer.example.test",
|
||||
browserTransactionDigest: "b".repeat(64),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
@@ -285,6 +292,7 @@ describe("file-backed auth session store", () => {
|
||||
.resolves.toMatchObject({
|
||||
nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/",
|
||||
authConfigRevision: revision, issuer: "https://issuer.example.test",
|
||||
browserTransactionDigest: "b".repeat(64),
|
||||
});
|
||||
await expect(store.consumeOidcState(created.state)).resolves.toBeUndefined();
|
||||
|
||||
|
||||
@@ -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