280 lines
11 KiB
TypeScript
280 lines
11 KiB
TypeScript
import { createSign, generateKeyPairSync } from "node:crypto";
|
|
import { expect, test } from "vitest";
|
|
import { createOidcProtocol, OidcProtocolError } 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[];
|
|
jwksResponse?: (init?: RequestInit) => Response | Promise<Response>;
|
|
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/")) {
|
|
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 === "/token") {
|
|
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.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,
|
|
});
|
|
}
|
|
|
|
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("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);
|
|
await expect(protocol({ discoveryIssuer: "https://other.example.test" }).authorizationUrl({ state, nonce, codeVerifier: verifier }))
|
|
.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" }],
|
|
["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("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("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);
|
|
});
|