From 1942447e90b5340aff192f13f0dcb4f6748b640d Mon Sep 17 00:00:00 2001 From: Andreas Fitzek Date: Mon, 5 Oct 2026 12:11:52 +0000 Subject: [PATCH] feat(core): Add a shared OAuth discovery and JWKS client (no-changelog) (#40012) Co-authored-by: Claude Fable 5.1 --- packages/@n8n/backend-services/package.json | 7 +- packages/@n8n/backend-services/src/index.ts | 10 + .../__tests__/oauth-discovery-client.test.ts | 405 ++++++++++++++++++ .../src/services/oauth-discovery-client.ts | 297 +++++++++++++ pnpm-lock.yaml | 6 + 5 files changed, 724 insertions(+), 1 deletion(-) create mode 100644 packages/@n8n/backend-services/src/services/__tests__/oauth-discovery-client.test.ts create mode 100644 packages/@n8n/backend-services/src/services/oauth-discovery-client.ts diff --git a/packages/@n8n/backend-services/package.json b/packages/@n8n/backend-services/package.json index aa080f00aff..124747ad60e 100644 --- a/packages/@n8n/backend-services/package.json +++ b/packages/@n8n/backend-services/package.json @@ -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" } diff --git a/packages/@n8n/backend-services/src/index.ts b/packages/@n8n/backend-services/src/index.ts index 42b54517e67..52fe2c096cb 100644 --- a/packages/@n8n/backend-services/src/index.ts +++ b/packages/@n8n/backend-services/src/index.ts @@ -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'; diff --git a/packages/@n8n/backend-services/src/services/__tests__/oauth-discovery-client.test.ts b/packages/@n8n/backend-services/src/services/__tests__/oauth-discovery-client.test.ts new file mode 100644 index 00000000000..d3d2ada57ae --- /dev/null +++ b/packages/@n8n/backend-services/src/services/__tests__/oauth-discovery-client.test.ts @@ -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 } = {}, +) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', ...headers }, + }); +} + +function metadata(overrides: Record = {}) { + 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(); + const outboundHttp = mock(); + 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>) { + 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('nope', { + 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 = + 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(); + }); + }); +}); diff --git a/packages/@n8n/backend-services/src/services/oauth-discovery-client.ts b/packages/@n8n/backend-services/src/services/oauth-discovery-client.ts new file mode 100644 index 00000000000..1c5922f90bf --- /dev/null +++ b/packages/@n8n/backend-services/src/services/oauth-discovery-client.ts @@ -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; + +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; + +export type Fetched = { 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 = { + 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 = { + 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('.') || ''}: ${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 | 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 | 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 { + 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 ?? ''}: ${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 | 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 { + 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) }; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f13054dd81a..e63d02410c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: