Files
ThothII/backend/test/oidc-client.test.ts
T

608 lines
23 KiB
TypeScript

import { createSign, generateKeyPairSync } from "node:crypto";
import { expect, test, vi } from "vitest";
import {
createOidcProtocol,
OidcIssuerMismatchError,
OidcJwksUnavailableError,
OidcDeviceFlowUnavailableError,
OidcProtocolError,
OidcProviderUnavailableError,
} from "../src/auth/oidc-client.js";
const issuer = "https://issuer.example.test";
const clientId = "thothii";
const callbackUrl = "https://thothii.example.test/api/auth/oidc/callback";
const verifier = "v".repeat(43);
const nonce = "n".repeat(43);
const state = "s".repeat(43);
const keys = generateKeyPairSync("rsa", { modulusLength: 2048 });
const jwk = { ...keys.publicKey.export({ format: "jwk" }), kid: "test-key", use: "sig", alg: "RS256" };
function token(claims: Record<string, unknown>, invalidSignature = false): string {
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
const input = `${encode({ alg: "RS256", kid: "test-key", typ: "JWT" })}.${encode(claims)}`;
const signer = createSign("RSA-SHA256");
signer.update(input);
signer.end();
const signature = signer.sign(keys.privateKey).toString("base64url");
const corruptedSignature = signature.startsWith("A") ? `B${signature.slice(1)}` : `A${signature.slice(1)}`;
return `${input}.${invalidSignature ? corruptedSignature : signature}`;
}
function protocol(options: {
claims?: Record<string, unknown>;
discoveryIssuer?: string;
invalidSignature?: boolean;
seen?: URL[];
discoveryResponse?: (init?: RequestInit) => Response | Promise<Response>;
tokenResponse?: (init?: RequestInit) => Response | Promise<Response>;
deviceResponse?: (init?: RequestInit) => Response | Promise<Response>;
jwksResponse?: (init?: RequestInit) => Response | Promise<Response>;
httpTimeoutMs?: number;
jwksTimeoutMs?: number;
discoveryMetadata?: Record<string, unknown>;
} = {}) {
const now = Math.floor(Date.now() / 1000);
const claims = {
iss: issuer,
sub: "user-123",
aud: clientId,
exp: now + 300,
iat: now,
nonce,
name: "Ada Lovelace",
groups: ["TOT Users", "Unmapped group"],
...options.claims,
};
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/")) {
if (options.discoveryResponse) return await options.discoveryResponse(init);
return Response.json({
issuer: options.discoveryIssuer ?? 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"],
...options.discoveryMetadata,
});
}
if (url.pathname === "/jwks") return options.jwksResponse ? await options.jwksResponse(init) : Response.json({ keys: [jwk] });
if (url.pathname === "/device") {
if (options.deviceResponse) return await options.deviceResponse(init);
return Response.json({
device_code: "device-code-must-not-be-persisted",
user_code: "ABCD-EFGH",
verification_uri: `${issuer}/device`,
expires_in: 60,
interval: 1,
});
}
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",
refresh_token: "refresh-token-must-not-be-persisted",
id_token: token(claims, options.invalidSignature),
});
}
return new Response(null, { status: 404 });
};
const protocolOptions = {
issuer,
clientId,
clientSecret: "client-secret-must-not-be-persisted",
callbackUrl,
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);
}
async function callback(subject = protocol()) {
return subject.callback({
currentUrl: new URL(`${callbackUrl}?code=good&state=${state}`), state, nonce, codeVerifier: verifier,
});
}
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 });
const authorization = await subject.authorizationUrl({ state, nonce, codeVerifier: verifier });
expect(authorization.origin).toBe(issuer);
expect(authorization.pathname).toBe("/authorize");
expect(Object.fromEntries(authorization.searchParams)).toMatchObject({
response_type: "code", client_id: clientId, redirect_uri: callbackUrl, state, nonce,
code_challenge_method: "S256", scope: "openid profile",
});
expect(authorization.searchParams.get("code_challenge")).not.toBe(verifier);
await expect(callback(subject)).resolves.toEqual({
issuer, subject: "user-123", displayName: "Ada Lovelace",
groups: ["TOT Users", "Unmapped group"], tokenExpiresAt: expect.any(Date),
});
expect(seen.map((url) => url.origin)).toEqual([issuer, issuer, issuer]);
});
test("uses a validated bounded OIDC device flow and returns only a verified direct-group identity", async () => {
const seen: URL[] = [];
const subject = protocol({
seen,
discoveryMetadata: { device_authorization_endpoint: `${issuer}/device` },
});
const presented: Array<[string, string]> = [];
await expect(subject.verifyDeviceFlow!(new AbortController().signal, (uri, code) => {
presented.push([uri, code]);
})).resolves.toMatchObject({
issuer,
subject: "user-123",
groups: ["TOT Users", "Unmapped group"],
});
expect(presented).toEqual([[`${issuer}/device`, "ABCD-EFGH"]]);
expect(seen.map((url) => url.pathname)).toEqual([
"/.well-known/openid-configuration", "/device", "/token", "/jwks",
]);
});
test("refuses device flow when discovery has no safe device authorization endpoint", async () => {
await expect(protocol().verifyDeviceFlow!(new AbortController().signal, () => undefined))
.rejects.toBeInstanceOf(OidcDeviceFlowUnavailableError);
});
test("stops authorization-pending polling at the provider device expiry", async () => {
const caller = new AbortController();
const timeout = vi.spyOn(AbortSignal, "timeout");
try {
const tokenResponse = vi.fn(async () => Response.json(
{ error: "authorization_pending", error_description: "pending" },
{ status: 400 },
));
const subject = protocol({
discoveryMetadata: { device_authorization_endpoint: `${issuer}/device` },
deviceResponse: async () => Response.json({
device_code: "ephemeral-device-code",
user_code: "ABCD-EFGH",
verification_uri: `${issuer}/device`,
expires_in: 0.05,
interval: 0.01,
}),
tokenResponse,
});
const completion = subject.verifyDeviceFlow!(caller.signal, () => undefined);
expect(await settlesWithin(completion, 250)).toBe("rejected");
expect(tokenResponse.mock.calls.length).toBeLessThanOrEqual(5);
expect(timeout).toHaveBeenCalledWith(10 * 60_000);
expect(timeout).toHaveBeenCalledWith(50);
await expect(completion).rejects.toThrow(OidcProtocolError);
} finally {
caller.abort();
timeout.mockRestore();
}
});
test("combines the caller cancellation with device-flow deadlines before provider requests", async () => {
const seen: URL[] = [];
const caller = new AbortController();
caller.abort(new DOMException("caller deadline", "AbortError"));
const subject = protocol({
seen,
discoveryMetadata: { device_authorization_endpoint: `${issuer}/device` },
});
await expect(subject.verifyDeviceFlow!(caller.signal, () => undefined)).rejects.toThrow(OidcProtocolError);
expect(seen).toEqual([]);
});
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,
scopes: ["openid"], groupsClaim: "groups",
})).toThrow(OidcProtocolError);
try {
await protocol({ discoveryIssuer: "https://other.example.test" })
.authorizationUrl({ state, nonce, codeVerifier: verifier });
throw new Error("issuer mismatch unexpectedly accepted");
} catch (error) {
expect(error).toBeInstanceOf(OidcIssuerMismatchError);
expect((error as Error).message).toBe("oidc_issuer_mismatch");
}
});
test("enforces the exact shared OIDC client-secret boundary", () => {
expect(() => createOidcProtocol({
issuer, clientId, clientSecret: "s".repeat(4096), callbackUrl,
scopes: ["openid"], groupsClaim: "groups",
})).not.toThrow();
expect(() => createOidcProtocol({
issuer, clientId, clientSecret: "s".repeat(4097), callbackUrl,
scopes: ["openid"], groupsClaim: "groups",
})).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" }],
["audience", new URL(`${callbackUrl}?code=good&state=${state}`), { aud: "someone-else" }],
["issuer", new URL(`${callbackUrl}?code=good&state=${state}`), { iss: "https://other.example.test" }],
["expiry", new URL(`${callbackUrl}?code=good&state=${state}`), { exp: Math.floor(Date.now() / 1000) - 1 }],
["subject", new URL(`${callbackUrl}?code=good&state=${state}`), { sub: undefined }],
])("rejects invalid %s claims or callback bindings", async (_label, currentUrl, claims) => {
const subject = protocol({ claims });
await expect(subject.callback({ currentUrl, state, nonce, codeVerifier: verifier })).rejects.toThrow(OidcProtocolError);
});
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("diagnose validates the bounded JWKS endpoint after discovery", async () => {
const subject = protocol({ jwksResponse: () => new Response("upstream JWKS body", { status: 503 }) });
await expect(subject.diagnose(new AbortController().signal)).rejects.toBeInstanceOf(OidcJwksUnavailableError);
});
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) {
pulls += 1;
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("rejects promptly and releases the JWKS reader when stream cancellation never settles", async () => {
let cancelled = false;
const body = new ReadableStream({
pull() { /* a non-success response is rejected before any body read */ },
cancel() {
cancelled = true;
return new Promise<void>(() => { /* deliberately never settles */ });
},
});
const subject = protocol({ jwksResponse: () => new Response(body, { status: 503 }) });
const completion = callback(subject).then(() => "resolved" as const, () => "rejected" as const);
const outcome = await Promise.race([
completion,
new Promise<"timed-out">((resolve) => setTimeout(() => resolve("timed-out"), 100)),
]);
expect(outcome).toBe("rejected");
expect(cancelled).toBe(true);
expect(body.locked).toBe(false);
});
test("preserves the JWKS body timeout when cancellation never settles", async () => {
let cancelled = false;
const body = new ReadableStream({
pull() { return new Promise<void>(() => { /* body read deliberately hangs */ }); },
cancel() {
cancelled = true;
return new Promise<void>(() => { /* deliberately never settles */ });
},
});
const subject = protocol({
jwksTimeoutMs: 20,
jwksResponse: () => new Response(body),
});
const completion = callback(subject).then(() => "resolved" as const, () => "rejected" as const);
const outcome = await Promise.race([
completion,
new Promise<"timed-out">((resolve) => setTimeout(() => resolve("timed-out"), 100)),
]);
expect(outcome).toBe("rejected");
expect(cancelled).toBe(true);
expect(body.locked).toBe(false);
});
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);
expect(body.locked).toBe(false);
});
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"] }],
["oversized", { groups: ["x".repeat(257)] }],
["distributed", { _claim_names: { groups: "source" }, _claim_sources: { source: { endpoint: "https://issuer.example.test/claims" } } }],
["overage", { hasgroups: true }],
])("rejects %s mandatory groups claims", async (_label, claims) => {
await expect(callback(protocol({ claims }))).rejects.toThrow(OidcProtocolError);
});