feat(core): Add a shared OAuth discovery and JWKS client (no-changelog) (#40012)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andreas Fitzek 2026-10-05 12:11:52 +00:00 committed by GitHub
parent 44d9adfc7e
commit 1942447e90
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 724 additions and 1 deletions

View File

@ -25,6 +25,7 @@
"dependencies": {
"@n8n/api-types": "workspace:*",
"@n8n/backend-common": "workspace:*",
"@n8n/backend-network": "workspace:*",
"@n8n/config": "workspace:*",
"@n8n/constants": "workspace:*",
"@n8n/decorators": "workspace:*",
@ -38,6 +39,9 @@
"ioredis": "5.3.2",
"n8n-workflow": "workspace:*"
},
"peerDependencies": {
"zod": "catalog:"
},
"devDependencies": {
"@n8n/backend-test-utils": "workspace:*",
"@n8n/eslint-config": "workspace:*",
@ -53,7 +57,8 @@
"oxlint-tsgolint": "catalog:",
"typescript": "catalog:typescript",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:"
"vitest-mock-extended": "catalog:",
"zod": "catalog:"
},
"license": "LicenseRef-n8n-sustainable-use"
}

View File

@ -34,3 +34,13 @@ export {
serializePublicApiError,
type InternalRestErrorBody,
} from './errors/rest-error-response';
export {
OAuthDiscoveryClient,
AuthorizationServerMetadataSchema,
JwkSchema,
type AuthorizationServerMetadata,
type Fetched,
type FetchedJwks,
type Jwk,
type SkippedJwk,
} from './services/oauth-discovery-client';

View File

@ -0,0 +1,405 @@
import type { OutboundHttp } from '@n8n/backend-network';
import { OperationalError, UserError } from 'n8n-workflow';
import { generateKeyPairSync } from 'node:crypto';
import { mock } from 'vitest-mock-extended';
import { OAuthDiscoveryClient } from '../oauth-discovery-client';
const ISSUER = 'https://idp.example';
const JWKS_URL = 'https://idp.example/keys';
function jsonResponse(
body: unknown,
{ status = 200, headers = {} }: { status?: number; headers?: Record<string, string> } = {},
) {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json', ...headers },
});
}
function metadata(overrides: Record<string, unknown> = {}) {
return {
issuer: ISSUER,
jwks_uri: JWKS_URL,
authorization_endpoint: 'https://idp.example/authorize',
token_endpoint: 'https://idp.example/token',
...overrides,
};
}
function createClient() {
const fetchMock = vi.fn<typeof globalThis.fetch>();
const outboundHttp = mock<OutboundHttp>();
outboundHttp.transport.mockReturnValue({
asCustomFetch: () => fetchMock,
getDispatcher: vi.fn(),
getNodeAgent: vi.fn(),
});
const client = new OAuthDiscoveryClient(outboundHttp);
return { client, fetchMock, outboundHttp };
}
/** The url and init of the first request made through the transport fetch. */
function firstRequest(fetchMock: ReturnType<typeof vi.fn<typeof globalThis.fetch>>) {
const [input, init] = fetchMock.mock.calls[0];
const url = input instanceof Request ? input.url : input.toString();
return { url, init };
}
// Real key material so the implementation can run the JWK through `createPublicKey`.
const rsaJwk = generateKeyPairSync('rsa', { modulusLength: 2048 }).publicKey.export({
format: 'jwk',
});
const ecJwk = generateKeyPairSync('ec', { namedCurve: 'P-256' }).publicKey.export({
format: 'jwk',
});
const rsaSigKey = { ...rsaJwk, kid: 'rsa1', use: 'sig', alg: 'RS256' };
const ecSigKey = { ...ecJwk, kid: 'ec1', alg: 'ES256' };
describe('OAuthDiscoveryClient', () => {
describe('transport', () => {
it('requests the transport with the enforced SSRF policy', () => {
const { outboundHttp } = createClient();
expect(outboundHttp.transport).toHaveBeenCalledTimes(1);
expect(outboundHttp.transport).toHaveBeenCalledWith({ useDefaultSsrfPolicy: 'enforced' });
});
});
describe('URL derivation', () => {
it('derives the OIDC discovery url from the issuer', async () => {
const { client, fetchMock } = createClient();
fetchMock.mockResolvedValue(jsonResponse(metadata()));
await client.fetchOpenIdConfiguration(ISSUER);
expect(firstRequest(fetchMock).url).toBe(
'https://idp.example/.well-known/openid-configuration',
);
});
it('does not double the slash when the issuer has a trailing slash', async () => {
const { client, fetchMock } = createClient();
fetchMock.mockResolvedValue(
jsonResponse(metadata({ issuer: 'https://idp.example/tenant/' })),
);
await client.fetchOpenIdConfiguration('https://idp.example/tenant/');
expect(firstRequest(fetchMock).url).toBe(
'https://idp.example/tenant/.well-known/openid-configuration',
);
});
it('derives the RFC 8414 url from an issuer without a path', async () => {
const { client, fetchMock } = createClient();
fetchMock.mockResolvedValue(jsonResponse(metadata()));
await client.fetchOAuth2ServerMetadata({ issuer: ISSUER });
expect(firstRequest(fetchMock).url).toBe(
'https://idp.example/.well-known/oauth-authorization-server',
);
});
it('inserts the well-known segment before the issuer path (RFC 8414)', async () => {
const { client, fetchMock } = createClient();
fetchMock.mockResolvedValue(jsonResponse(metadata({ issuer: 'https://idp.example/tenant' })));
await client.fetchOAuth2ServerMetadata({ issuer: 'https://idp.example/tenant' });
expect(firstRequest(fetchMock).url).toBe(
'https://idp.example/.well-known/oauth-authorization-server/tenant',
);
});
it('requests exactly the manual metadata url when one is given', async () => {
const { client, fetchMock } = createClient();
fetchMock.mockResolvedValue(jsonResponse(metadata()));
await client.fetchOAuth2ServerMetadata({
issuer: ISSUER,
url: 'https://idp.example/custom/metadata',
});
expect(firstRequest(fetchMock).url).toBe('https://idp.example/custom/metadata');
});
it('sends every request with redirect error, an abort signal and a JSON accept header', async () => {
const { client, fetchMock } = createClient();
fetchMock.mockResolvedValue(jsonResponse(metadata()));
await client.fetchOpenIdConfiguration(ISSUER);
const { init } = firstRequest(fetchMock);
expect(init?.redirect).toBe('error');
expect(init?.signal).toBeInstanceOf(AbortSignal);
expect(new Headers(init?.headers).get('accept')).toBe('application/json');
});
});
describe('issuer and url guards', () => {
it.each([
['http scheme', 'http://idp.example'],
['query string', 'https://idp.example/?x=1'],
['fragment', 'https://idp.example/#f'],
])('rejects an issuer with a %s before fetching (OIDC path)', async (_, issuer) => {
const { client, fetchMock } = createClient();
await expect(client.fetchOpenIdConfiguration(issuer)).rejects.toBeInstanceOf(UserError);
expect(fetchMock).not.toHaveBeenCalled();
});
it.each([
['http scheme', 'http://idp.example'],
['query string', 'https://idp.example/?x=1'],
['fragment', 'https://idp.example/#f'],
])('rejects an issuer with a %s before fetching (RFC 8414 path)', async (_, issuer) => {
const { client, fetchMock } = createClient();
await expect(client.fetchOAuth2ServerMetadata({ issuer })).rejects.toBeInstanceOf(UserError);
expect(fetchMock).not.toHaveBeenCalled();
});
it('rejects a manual metadata url that is not https before fetching', async () => {
const { client, fetchMock } = createClient();
await expect(
client.fetchOAuth2ServerMetadata({ issuer: ISSUER, url: 'http://idp.example/metadata' }),
).rejects.toBeInstanceOf(UserError);
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe('metadata responses', () => {
it('returns the document with unknown fields kept and a ttl', async () => {
const { client, fetchMock } = createClient();
fetchMock.mockResolvedValue(jsonResponse(metadata({ foo: 'bar' })));
const result = await client.fetchOpenIdConfiguration(ISSUER);
expect(result).toBeDefined();
expect(result?.document).toMatchObject(metadata({ foo: 'bar' }));
expect(result?.document.foo).toBe('bar');
expect(typeof result?.ttlSeconds).toBe('number');
});
it('rejects when the returned issuer differs from the requested one', async () => {
const { client, fetchMock } = createClient();
fetchMock.mockResolvedValue(jsonResponse(metadata({ issuer: 'https://idp.example/' })));
await expect(client.fetchOpenIdConfiguration(ISSUER)).rejects.toBeInstanceOf(
OperationalError,
);
});
it('resolves to undefined on 404 for the OIDC document', async () => {
const { client, fetchMock } = createClient();
fetchMock.mockResolvedValue(jsonResponse({ error: 'not found' }, { status: 404 }));
await expect(client.fetchOpenIdConfiguration(ISSUER)).resolves.toBeUndefined();
});
it('resolves to undefined on 404 for the RFC 8414 document', async () => {
const { client, fetchMock } = createClient();
fetchMock.mockResolvedValue(jsonResponse({ error: 'not found' }, { status: 404 }));
await expect(client.fetchOAuth2ServerMetadata({ issuer: ISSUER })).resolves.toBeUndefined();
});
it('rejects on a 500 response', async () => {
const { client, fetchMock } = createClient();
fetchMock.mockResolvedValue(jsonResponse({ error: 'boom' }, { status: 500 }));
await expect(client.fetchOpenIdConfiguration(ISSUER)).rejects.toBeInstanceOf(
OperationalError,
);
});
it('rejects when the body is not JSON', async () => {
const { client, fetchMock } = createClient();
fetchMock.mockResolvedValue(
new Response('<html>nope</html>', {
status: 200,
headers: { 'content-type': 'text/html' },
}),
);
await expect(client.fetchOpenIdConfiguration(ISSUER)).rejects.toBeInstanceOf(
OperationalError,
);
});
it('rejects when jwks_uri is not https', async () => {
const { client, fetchMock } = createClient();
fetchMock.mockResolvedValue(jsonResponse(metadata({ jwks_uri: 'http://idp.example/keys' })));
await expect(client.fetchOpenIdConfiguration(ISSUER)).rejects.toBeInstanceOf(
OperationalError,
);
});
it('rejects when the body has no issuer', async () => {
const { client, fetchMock } = createClient();
const { issuer: _dropped, ...withoutIssuer } = metadata();
fetchMock.mockResolvedValue(jsonResponse(withoutIssuer));
await expect(client.fetchOpenIdConfiguration(ISSUER)).rejects.toBeInstanceOf(
OperationalError,
);
});
it('rejects a body larger than 1 MiB', async () => {
const { client, fetchMock } = createClient();
fetchMock.mockResolvedValue(jsonResponse({ issuer: ISSUER, pad: 'x'.repeat(1024 * 1024) }));
await expect(client.fetchOpenIdConfiguration(ISSUER)).rejects.toBeInstanceOf(
OperationalError,
);
});
it('wraps a network failure in an OperationalError that names the url', async () => {
const { client, fetchMock } = createClient();
fetchMock.mockRejectedValue(new TypeError('fetch failed'));
const promise = client.fetchOpenIdConfiguration(ISSUER);
await expect(promise).rejects.toBeInstanceOf(OperationalError);
await expect(promise).rejects.toThrow('https://idp.example/.well-known/openid-configuration');
});
});
describe('ttl from Cache-Control', () => {
it.each([
{ label: 'absent', cacheControl: undefined, expected: 3600 },
{ label: 'max-age=10', cacheControl: 'max-age=10', expected: 60 },
{ label: 'max-age=600', cacheControl: 'max-age=600', expected: 600 },
{ label: 'max-age=1000000', cacheControl: 'max-age=1000000', expected: 86400 },
{ label: 'public, max-age=300', cacheControl: 'public, max-age=300', expected: 300 },
])('Cache-Control $label gives ttlSeconds $expected', async ({ cacheControl, expected }) => {
const { client, fetchMock } = createClient();
const headers: Record<string, string> =
cacheControl === undefined ? {} : { 'cache-control': cacheControl };
fetchMock.mockResolvedValue(jsonResponse(metadata(), { headers }));
const result = await client.fetchOpenIdConfiguration(ISSUER);
expect(result?.ttlSeconds).toBe(expected);
});
});
describe('fetchJwks', () => {
it('returns RSA and EC signing keys in order with their material kept', async () => {
const { client, fetchMock } = createClient();
fetchMock.mockResolvedValue(jsonResponse({ keys: [rsaSigKey, ecSigKey] }));
const result = await client.fetchJwks(JWKS_URL);
expect(result.keys.map((k) => k.kid)).toEqual(['rsa1', 'ec1']);
expect(result.keys[0]).toMatchObject({ kty: 'RSA', n: rsaJwk.n, e: rsaJwk.e });
expect(result.keys[1]).toMatchObject({ kty: 'EC', x: ecJwk.x, y: ecJwk.y });
expect(result.skipped).toEqual([]);
});
it('skips a key with use enc', async () => {
const { client, fetchMock } = createClient();
fetchMock.mockResolvedValue(
jsonResponse({ keys: [rsaSigKey, { ...rsaJwk, kid: 'enc1', use: 'enc', alg: 'RS256' }] }),
);
const result = await client.fetchJwks(JWKS_URL);
expect(result.keys.map((k) => k.kid)).toEqual(['rsa1']);
expect(result.skipped).toEqual([{ kid: 'enc1', reason: expect.stringContaining('enc') }]);
});
it('skips a key with kty oct', async () => {
const { client, fetchMock } = createClient();
fetchMock.mockResolvedValue(
jsonResponse({ keys: [rsaSigKey, { kty: 'oct', kid: 'oct1', k: 'c2VjcmV0' }] }),
);
const result = await client.fetchJwks(JWKS_URL);
expect(result.keys.map((k) => k.kid)).toEqual(['rsa1']);
expect(result.skipped).toEqual([{ kid: 'oct1', reason: expect.any(String) }]);
});
it('skips a key whose alg does not match its kty', async () => {
const { client, fetchMock } = createClient();
fetchMock.mockResolvedValue(
jsonResponse({ keys: [rsaSigKey, { ...rsaJwk, kid: 'mismatch', alg: 'ES256' }] }),
);
const result = await client.fetchJwks(JWKS_URL);
expect(result.keys.map((k) => k.kid)).toEqual(['rsa1']);
expect(result.skipped).toEqual([{ kid: 'mismatch', reason: expect.any(String) }]);
});
it('skips a key with an unsupported alg', async () => {
const { client, fetchMock } = createClient();
fetchMock.mockResolvedValue(
jsonResponse({ keys: [rsaSigKey, { ...rsaJwk, kid: 'hmac', alg: 'HS256' }] }),
);
const result = await client.fetchJwks(JWKS_URL);
expect(result.keys.map((k) => k.kid)).toEqual(['rsa1']);
expect(result.skipped).toEqual([{ kid: 'hmac', reason: expect.any(String) }]);
});
it('skips an RSA key with unusable material', async () => {
const { client, fetchMock } = createClient();
fetchMock.mockResolvedValue(
jsonResponse({
keys: [rsaSigKey, { kty: 'RSA', kid: 'garbage', alg: 'RS256', n: '!!!', e: 'AQAB' }],
}),
);
const result = await client.fetchJwks(JWKS_URL);
expect(result.keys.map((k) => k.kid)).toEqual(['rsa1']);
expect(result.skipped).toEqual([{ kid: 'garbage', reason: expect.any(String) }]);
});
it('rejects when every key is skipped', async () => {
const { client, fetchMock } = createClient();
fetchMock.mockResolvedValue(
jsonResponse({ keys: [{ ...rsaJwk, kid: 'enc1', use: 'enc', alg: 'RS256' }] }),
);
await expect(client.fetchJwks(JWKS_URL)).rejects.toBeInstanceOf(OperationalError);
});
it('rejects on 404', async () => {
const { client, fetchMock } = createClient();
fetchMock.mockResolvedValue(jsonResponse({ error: 'not found' }, { status: 404 }));
await expect(client.fetchJwks(JWKS_URL)).rejects.toBeInstanceOf(OperationalError);
});
it('reads ttlSeconds from Cache-Control', async () => {
const { client, fetchMock } = createClient();
fetchMock.mockResolvedValue(
jsonResponse({ keys: [rsaSigKey] }, { headers: { 'cache-control': 'max-age=600' } }),
);
const result = await client.fetchJwks(JWKS_URL);
expect(result.ttlSeconds).toBe(600);
});
it('rejects a url that is not https before fetching', async () => {
const { client, fetchMock } = createClient();
await expect(client.fetchJwks('http://idp.example/keys')).rejects.toBeInstanceOf(UserError);
expect(fetchMock).not.toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,297 @@
import { OutboundHttp } from '@n8n/backend-network';
import { Service } from '@n8n/di';
import { isRecord } from '@n8n/utils/is-record';
import { OperationalError, UserError } from 'n8n-workflow';
import { createPublicKey } from 'node:crypto';
import { z } from 'zod';
const TIMEOUT_MS = 10_000;
const MAX_BODY_BYTES = 1024 * 1024;
const MIN_TTL_SECONDS = 60;
const DEFAULT_TTL_SECONDS = 3600;
const MAX_TTL_SECONDS = 86_400;
// An admin-supplied endpoint is fetched server-side, so it must be https.
const HttpsUrl = z
.string()
.url()
.refine((url) => url.startsWith('https://'), { message: 'must be an https URL' });
/** RFC 8414 fields. OIDC Discovery documents carry the same core fields, so one schema reads both. */
export const AuthorizationServerMetadataSchema = z
.object({
issuer: z.string().min(1),
jwks_uri: HttpsUrl.optional(),
authorization_endpoint: HttpsUrl.optional(),
token_endpoint: HttpsUrl.optional(),
})
.passthrough();
export type AuthorizationServerMetadata = z.infer<typeof AuthorizationServerMetadataSchema>;
export const JwkSchema = z
.object({
kid: z.string().optional(),
kty: z.enum(['RSA', 'EC', 'OKP']),
use: z.string().optional(),
alg: z.string().optional(),
})
.passthrough();
export type Jwk = z.infer<typeof JwkSchema>;
export type Fetched<T> = { document: T; ttlSeconds: number };
export type SkippedJwk = { kid?: string; reason: string };
export type FetchedJwks = { keys: Jwk[]; skipped: SkippedJwk[]; ttlSeconds: number };
const JwkSetSchema = z.object({ keys: z.array(z.unknown()) });
/** Key type each supported JWS signing algorithm needs. Any other `alg` is not accepted. */
const ALG_KEY_TYPE: Record<string, Jwk['kty']> = {
RS256: 'RSA',
RS384: 'RSA',
RS512: 'RSA',
PS256: 'RSA',
PS384: 'RSA',
PS512: 'RSA',
ES256: 'EC',
ES384: 'EC',
ES512: 'EC',
EdDSA: 'OKP',
};
/** Public key material each key type must carry (RFC 7518 section 6). */
const REQUIRED_MATERIAL: Record<Jwk['kty'], string[]> = {
RSA: ['n', 'e'],
EC: ['x', 'y'],
OKP: ['x'],
};
// Node's base64url decoder drops characters it cannot decode instead of failing, so
// `createPublicKey` turns garbage material into an empty key. Check the encoding first.
const BASE64URL = /^[A-Za-z0-9_-]+=*$/;
type FetchedJson = { body: unknown; ttlSeconds: number };
function parseIssuer(issuer: string): URL {
let url: URL;
try {
url = new URL(issuer);
} catch {
throw new UserError(`Issuer "${issuer}" is not a valid URL`);
}
if (url.protocol !== 'https:') {
throw new UserError(`Issuer "${issuer}" must use https`);
}
if (url.search !== '' || url.hash !== '') {
throw new UserError(`Issuer "${issuer}" must not have a query string or fragment`);
}
return url;
}
function assertHttpsUrl(url: string, label: string): void {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new UserError(`${label} "${url}" is not a valid URL`);
}
if (parsed.protocol !== 'https:') {
throw new UserError(`${label} "${url}" must use https`);
}
}
function parseMaxAge(cacheControl: string | null): number | undefined {
if (!cacheControl) return undefined;
const match = /(?:^|[\s,])max-age=(\d+)/i.exec(cacheControl);
return match ? Number.parseInt(match[1], 10) : undefined;
}
function ttlFromHeaders(headers: Headers): number {
const maxAge = parseMaxAge(headers.get('cache-control')) ?? DEFAULT_TTL_SECONDS;
return Math.max(MIN_TTL_SECONDS, Math.min(maxAge, MAX_TTL_SECONDS));
}
function formatIssues(error: z.ZodError): string {
return error.issues
.map((issue) => `${issue.path.join('.') || '<root>'}: ${issue.message}`)
.join('; ');
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function toSigningKey(raw: unknown): { key: Jwk } | { skipped: SkippedJwk } {
const parsed = JwkSchema.safeParse(raw);
if (!parsed.success) {
const kid = isRecord(raw) && typeof raw.kid === 'string' ? raw.kid : undefined;
return { skipped: { kid, reason: `invalid JWK: ${formatIssues(parsed.error)}` } };
}
const jwk = parsed.data;
const skip = (reason: string) => ({ skipped: { kid: jwk.kid, reason } });
if (jwk.use === 'enc') {
return skip('use is "enc", not a signing key');
}
if (jwk.alg !== undefined) {
const expectedKty = ALG_KEY_TYPE[jwk.alg];
if (expectedKty === undefined) {
return skip(`unsupported alg "${jwk.alg}"`);
}
if (expectedKty !== jwk.kty) {
return skip(`alg "${jwk.alg}" does not match kty "${jwk.kty}"`);
}
}
for (const field of REQUIRED_MATERIAL[jwk.kty]) {
const value = jwk[field];
if (typeof value !== 'string' || !BASE64URL.test(value)) {
return skip(`"${field}" is not base64url key material`);
}
}
try {
createPublicKey({ key: jwk, format: 'jwk' });
} catch (error) {
return skip(`key material is unusable: ${errorMessage(error)}`);
}
return { key: jwk };
}
@Service()
export class OAuthDiscoveryClient {
private readonly fetch: typeof globalThis.fetch;
constructor(outboundHttp: OutboundHttp) {
// Admin-supplied URLs are a server-side fetch: the guard runs even when the instance flag is off.
this.fetch = outboundHttp.transport({ useDefaultSsrfPolicy: 'enforced' }).asCustomFetch();
}
/** `undefined` when the issuer answers 404: the document is not offered, which is not an error. */
async fetchOpenIdConfiguration(
issuer: string,
): Promise<Fetched<AuthorizationServerMetadata> | undefined> {
parseIssuer(issuer);
// OIDC Discovery appends to the issuer; only one slash may separate the two parts.
const url = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`;
return await this.fetchMetadata(issuer, url);
}
async fetchOAuth2ServerMetadata({
issuer,
url,
}: {
issuer: string;
url?: string;
}): Promise<Fetched<AuthorizationServerMetadata> | undefined> {
const issuerUrl = parseIssuer(issuer);
if (url !== undefined) {
assertHttpsUrl(url, 'Metadata URL');
return await this.fetchMetadata(issuer, url);
}
// RFC 8414 inserts the well-known segment between the host and the issuer path.
const path = issuerUrl.pathname === '/' ? '' : issuerUrl.pathname.replace(/\/$/, '');
return await this.fetchMetadata(
issuer,
`${issuerUrl.origin}/.well-known/oauth-authorization-server${path}`,
);
}
async fetchJwks(url: string): Promise<FetchedJwks> {
assertHttpsUrl(url, 'JWKS URL');
const fetched = await this.fetchJson(url);
if (fetched === undefined) {
throw new OperationalError(`Request to "${url}" failed: HTTP 404`);
}
const set = JwkSetSchema.safeParse(fetched.body);
if (!set.success) {
throw new OperationalError(`Response from "${url}" is not a JWK Set`);
}
const keys: Jwk[] = [];
const skipped: SkippedJwk[] = [];
for (const raw of set.data.keys) {
const result = toSigningKey(raw);
if ('key' in result) {
keys.push(result.key);
} else {
skipped.push(result.skipped);
}
}
if (keys.length === 0) {
const reasons = skipped.map((s) => `${s.kid ?? '<no kid>'}: ${s.reason}`).join('; ');
throw new OperationalError(
`JWKS at "${url}" has no usable signing keys${reasons ? ` (${reasons})` : ''}`,
);
}
return { keys, skipped, ttlSeconds: fetched.ttlSeconds };
}
private async fetchMetadata(
issuer: string,
url: string,
): Promise<Fetched<AuthorizationServerMetadata> | undefined> {
const fetched = await this.fetchJson(url);
if (fetched === undefined) return undefined;
const parsed = AuthorizationServerMetadataSchema.safeParse(fetched.body);
if (!parsed.success) {
throw new OperationalError(
`Response from "${url}" is not a valid metadata document: ${formatIssues(parsed.error)}`,
);
}
// RFC 8414 section 3.3: the document must name the configured issuer, compared byte for byte.
if (parsed.data.issuer !== issuer) {
throw new OperationalError(
`Metadata from "${url}" names issuer "${parsed.data.issuer}", expected "${issuer}"`,
);
}
return { document: parsed.data, ttlSeconds: fetched.ttlSeconds };
}
/** `undefined` on 404 so each caller decides whether a missing document is an error. */
private async fetchJson(url: string): Promise<FetchedJson | undefined> {
let response: Response;
try {
response = await this.fetch(url, {
method: 'GET',
headers: { accept: 'application/json' },
redirect: 'error',
signal: AbortSignal.timeout(TIMEOUT_MS),
});
} catch (error) {
throw new OperationalError(`Request to "${url}" failed: ${errorMessage(error)}`);
}
if (response.status === 404) return undefined;
if (!response.ok) {
throw new OperationalError(`Request to "${url}" failed: HTTP ${response.status}`);
}
let text: string;
try {
text = await response.text();
} catch (error) {
throw new OperationalError(`Request to "${url}" failed: ${errorMessage(error)}`);
}
if (Buffer.byteLength(text, 'utf8') > MAX_BODY_BYTES) {
throw new OperationalError(`Response from "${url}" exceeds ${MAX_BODY_BYTES} bytes`);
}
let body: unknown;
try {
body = JSON.parse(text);
} catch {
throw new OperationalError(`Response from "${url}" is not JSON`);
}
return { body, ttlSeconds: ttlFromHeaders(response.headers) };
}
}

View File

@ -1825,6 +1825,9 @@ importers:
'@n8n/backend-common':
specifier: workspace:*
version: link:../backend-common
'@n8n/backend-network':
specifier: workspace:*
version: link:../backend-network
'@n8n/config':
specifier: workspace:*
version: link:../config
@ -1907,6 +1910,9 @@ importers:
vitest-mock-extended:
specifier: 'catalog:'
version: 3.1.0(typescript@7.0.2)(vitest@5.0.1)
zod:
specifier: 3.25.76
version: 3.25.76
packages/@n8n/backend-test-utils:
dependencies: