mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
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:
parent
44d9adfc7e
commit
1942447e90
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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) };
|
||||
}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user