fix(auth): bound OIDC initiation and transport
This commit is contained in:
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user