feat(core): Inline JWKS in OAuth2 dynamic client registration (#29986)

This commit is contained in:
Guillaume Jacquart 2026-05-20 15:33:18 +02:00 committed by GitHub
parent 8026438d97
commit a4ff8358e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 459 additions and 47 deletions

View File

@ -23,6 +23,7 @@ export interface OAuth2CredentialData {
useDynamicClientRegistration?: boolean;
serverUrl?: string;
jweEnabled?: boolean;
inlineJwks?: boolean;
}
/**

View File

@ -1,7 +1,9 @@
import { mock } from 'jest-mock-extended';
import { CompactEncrypt, generateKeyPair } from 'jose';
import type { CryptoKey } from 'jose';
import { UserError } from 'n8n-workflow';
import { UnexpectedError, UserError } from 'n8n-workflow';
import type { UrlService } from '@/services/url.service';
import { OAuthJweDecryptService } from '../oauth-jwe-decrypt.service';
import type { OAuthJweKeyService } from '../oauth-jwe-key.service';
@ -35,7 +37,7 @@ describe('OAuthJweDecryptService', () => {
publicJwk: {},
kid: 'kid-1',
});
service = new OAuthJweDecryptService(keyService);
service = new OAuthJweDecryptService(keyService, mock<UrlService>());
});
it('decrypts both access_token and id_token when both are JWEs', async () => {
@ -96,4 +98,72 @@ describe('OAuthJweDecryptService', () => {
it('throws when the JWE is malformed', async () => {
await expect(service.decryptOAuth2TokenData({ access_token: 'a.b.c.d.e' })).rejects.toThrow();
});
describe('getDcrJweFields', () => {
const JWKS_URI = 'http://localhost:5678/rest/.well-known/jwks.json';
const PUBLIC_JWK = { kty: 'RSA', alg: ALG, kid: 'kid-active', n: 'n', e: 'AQAB' };
function buildService(jwk: Record<string, string> = PUBLIC_JWK) {
const keyService = mock<OAuthJweKeyService>();
keyService.getPublicJwk.mockResolvedValue(jwk);
const urlService = mock<UrlService>();
urlService.getInstanceJwksUri.mockReturnValue(JWKS_URI);
return new OAuthJweDecryptService(keyService, urlService);
}
it('returns jwks_uri (not jwks) when inlineJwks is false', async () => {
const localService = buildService();
const fields = await localService.getDcrJweFields(false);
expect(fields).toEqual({
jwks_uri: JWKS_URI,
id_token_encrypted_response_alg: ALG,
});
expect(fields).not.toHaveProperty('jwks');
});
it('returns jwks (not jwks_uri) when inlineJwks is true', async () => {
const localService = buildService();
const fields = await localService.getDcrJweFields(true);
expect(fields).toEqual({
jwks: { keys: [PUBLIC_JWK] },
id_token_encrypted_response_alg: ALG,
});
expect(fields).not.toHaveProperty('jwks_uri');
});
it('uses whatever alg the active key advertises', async () => {
const localService = buildService({
kty: 'EC',
alg: 'ECDH-ES+A256KW',
kid: 'kid-ec',
crv: 'P-256',
x: 'x',
y: 'y',
});
const fields = await localService.getDcrJweFields(false);
expect(fields.id_token_encrypted_response_alg).toBe('ECDH-ES+A256KW');
});
it('throws when the active key has no alg', async () => {
const keyService = mock<OAuthJweKeyService>();
keyService.getPublicJwk.mockResolvedValue({
kty: 'RSA',
kid: 'kid-no-alg',
n: 'n',
e: 'AQAB',
});
const localService = new OAuthJweDecryptService(keyService, mock<UrlService>());
await expect(localService.getDcrJweFields(false)).rejects.toThrow(UnexpectedError);
await expect(localService.getDcrJweFields(false)).rejects.toThrow(
'OAuth JWE public key is missing an "alg" field',
);
});
});
});

View File

@ -74,6 +74,45 @@ describe('decryptJweToken', () => {
it('throws on a malformed token', async () => {
await expect(decryptJweToken('not.a.real.jwe.token', privateKey)).rejects.toThrow();
});
it.each(['A128CBC-HS256', 'A192CBC-HS384', 'A256CBC-HS512', 'A128GCM', 'A192GCM', 'A256GCM'])(
'decrypts when enc is "%s"',
async (enc) => {
const token = await new CompactEncrypt(new TextEncoder().encode(`payload-${enc}`))
.setProtectedHeader({ alg: ALG, enc })
.encrypt(publicKey);
const result = await decryptJweToken(token, privateKey);
expect(result).toBe(`payload-${enc}`);
},
);
it('rephrases jose JOSENotSupported into a domain-specific UserError naming the enc value', async () => {
const header = Buffer.from(
JSON.stringify({ alg: ALG, enc: 'UNSUPPORTED-ENC' }),
'utf8',
).toString('base64url');
const token = `${header}.aaa.bbb.ccc.ddd`;
await expect(decryptJweToken(token, privateKey)).rejects.toThrow(
/Re-register the client at the IdP/,
);
await expect(decryptJweToken(token, privateKey)).rejects.toThrow(/enc="UNSUPPORTED-ENC"/);
});
it('rephrases jose JOSENotSupported into a domain-specific UserError naming the alg value', async () => {
const header = Buffer.from(
JSON.stringify({ alg: 'UNSUPPORTED-ALG', enc: 'A256GCM' }),
'utf8',
).toString('base64url');
const token = `${header}.aaa.bbb.ccc.ddd`;
await expect(decryptJweToken(token, privateKey)).rejects.toThrow(
/Re-register the client at the IdP/,
);
await expect(decryptJweToken(token, privateKey)).rejects.toThrow(/alg="UNSUPPORTED-ALG"/);
});
});
describe('decryptJweTokenData', () => {

View File

@ -1,6 +1,9 @@
import { Service } from '@n8n/di';
import type { IDataObject } from 'n8n-workflow';
import { UserError } from 'n8n-workflow';
import { UnexpectedError, UserError } from 'n8n-workflow';
import type { DcrJweFields } from '@/oauth/oauth-jwe-service.proxy';
import { UrlService } from '@/services/url.service';
import { OAuthJweKeyService } from './oauth-jwe-key.service';
import { decryptJweToken, isJweToken } from './oauth-jwe.utils';
@ -9,13 +12,31 @@ const JWE_TOKEN_FIELDS = ['access_token', 'id_token'] as const;
/**
* Decrypts JWE-wrapped OAuth2 token responses using the active instance
* private key. Callers must already have decided that the credential
* expects JWE this service rejects responses with no JWE tokens at all
* so a misconfigured IdP cannot silently downgrade the channel.
* private key, and produces the JWE-related fields of an RFC 7591 dynamic
* client registration payload. Callers must already have decided that the
* credential expects JWE the decrypt path rejects responses with no JWE
* tokens at all so a misconfigured IdP cannot silently downgrade the channel.
*/
@Service()
export class OAuthJweDecryptService {
constructor(private readonly keyService: OAuthJweKeyService) {}
constructor(
private readonly keyService: OAuthJweKeyService,
private readonly urlService: UrlService,
) {}
async getDcrJweFields(inlineJwks: boolean): Promise<DcrJweFields> {
const publicJwk = await this.keyService.getPublicJwk();
if (typeof publicJwk.alg !== 'string' || publicJwk.alg.length === 0) {
throw new UnexpectedError('OAuth JWE public key is missing an "alg" field');
}
const keyDistribution: Pick<DcrJweFields, 'jwks' | 'jwks_uri'> = inlineJwks
? { jwks: { keys: [publicJwk] } }
: { jwks_uri: this.urlService.getInstanceJwksUri() };
return {
...keyDistribution,
id_token_encrypted_response_alg: publicJwk.alg,
};
}
async decryptOAuth2TokenData(tokenData: IDataObject): Promise<IDataObject> {
const { privateKey } = await this.keyService.getKeyPair();

View File

@ -1,5 +1,6 @@
import type { CryptoKey } from 'jose';
import { compactDecrypt } from 'jose';
import { compactDecrypt, errors as joseErrors } from 'jose';
import { UserError } from 'n8n-workflow';
const JWE_SEGMENT_COUNT = 5;
@ -18,10 +19,38 @@ export function isJweToken(token: unknown): token is string {
* Decrypts a compact-serialisation JWE token and returns the plaintext payload
* as a UTF-8 string. The caller is responsible for any further handling
* (e.g. verifying an inner JWT).
*
* Wraps jose's {@link joseErrors.JOSENotSupported} into a domain-specific
* {@link UserError} that surfaces the offending header so the admin sees an
* actionable "re-register at the IdP" message instead of a generic library
* error. Other jose errors (bad key, malformed token) propagate unchanged.
*/
export async function decryptJweToken(token: string, privateKey: CryptoKey): Promise<string> {
const { plaintext } = await compactDecrypt(token, privateKey);
return new TextDecoder().decode(plaintext);
try {
const { plaintext } = await compactDecrypt(token, privateKey);
return new TextDecoder().decode(plaintext);
} catch (error) {
if (error instanceof joseErrors.JOSENotSupported) {
throw new UserError(
`Cannot decrypt token: ${error.message}${formatHeaderHint(token)}. Re-register the client at the IdP with a standard JWE algorithm (RFC 7518).`,
);
}
throw error;
}
}
function formatHeaderHint(token: string): string {
const [headerSegment] = token.split('.');
let header: { alg?: unknown; enc?: unknown };
try {
header = JSON.parse(Buffer.from(headerSegment, 'base64url').toString('utf8')) as typeof header;
} catch {
return '';
}
const parts: string[] = [];
if (typeof header.alg === 'string') parts.push(`alg="${header.alg}"`);
if (typeof header.enc === 'string') parts.push(`enc="${header.enc}"`);
return parts.length ? ` (${parts.join(', ')})` : '';
}
/**

View File

@ -1,38 +1,83 @@
import { mock } from 'jest-mock-extended';
import { UserError } from 'n8n-workflow';
import { OAuthJweServiceProxy, type OAuthJweDecryptHandler } from '@/oauth/oauth-jwe-service.proxy';
import { OAuthJweServiceProxy, type OAuthJweHandler } from '@/oauth/oauth-jwe-service.proxy';
describe('OAuthJweServiceProxy', () => {
it('throws when no handler has been registered', async () => {
const proxy = new OAuthJweServiceProxy();
describe('decryptOAuth2TokenData', () => {
const NO_HANDLER_MESSAGE =
'OAuth2 JWE decryption was requested but the feature is not enabled on this instance';
await expect(proxy.decryptOAuth2TokenData({ access_token: 'a' })).rejects.toThrow(UserError);
await expect(proxy.decryptOAuth2TokenData({ access_token: 'a' })).rejects.toThrow(
'OAuth2 JWE decryption was requested but the feature is not enabled on this instance',
);
it('throws when no handler has been registered', async () => {
const proxy = new OAuthJweServiceProxy();
await expect(proxy.decryptOAuth2TokenData({ access_token: 'a' })).rejects.toThrow(UserError);
await expect(proxy.decryptOAuth2TokenData({ access_token: 'a' })).rejects.toThrow(
NO_HANDLER_MESSAGE,
);
});
it('delegates to the registered handler', async () => {
const handler = mock<OAuthJweHandler>();
handler.decryptOAuth2TokenData.mockResolvedValue({ access_token: 'decrypted' });
const proxy = new OAuthJweServiceProxy();
proxy.setHandler(handler);
const result = await proxy.decryptOAuth2TokenData({ access_token: 'a' });
expect(handler.decryptOAuth2TokenData).toHaveBeenCalledWith({ access_token: 'a' });
expect(result).toEqual({ access_token: 'decrypted' });
});
it('propagates errors thrown by the handler', async () => {
const handler = mock<OAuthJweHandler>();
handler.decryptOAuth2TokenData.mockRejectedValue(new Error('decrypt failed'));
const proxy = new OAuthJweServiceProxy();
proxy.setHandler(handler);
await expect(proxy.decryptOAuth2TokenData({ access_token: 'a' })).rejects.toThrow(
'decrypt failed',
);
});
});
it('delegates to the registered handler', async () => {
const handler = mock<OAuthJweDecryptHandler>();
handler.decryptOAuth2TokenData.mockResolvedValue({ access_token: 'decrypted' });
const proxy = new OAuthJweServiceProxy();
proxy.setHandler(handler);
describe('getDcrJweFields', () => {
it('returns an empty object when no handler is registered (feature off)', async () => {
const proxy = new OAuthJweServiceProxy();
const result = await proxy.decryptOAuth2TokenData({ access_token: 'a' });
await expect(proxy.getDcrJweFields(false)).resolves.toEqual({});
});
expect(handler.decryptOAuth2TokenData).toHaveBeenCalledWith({ access_token: 'a' });
expect(result).toEqual({ access_token: 'decrypted' });
});
it('delegates with inlineJwks=false to the registered handler', async () => {
const fields = {
jwks_uri: 'http://localhost:5678/rest/.well-known/jwks.json',
id_token_encrypted_response_alg: 'RSA-OAEP-256',
};
const handler = mock<OAuthJweHandler>();
handler.getDcrJweFields.mockResolvedValue(fields);
const proxy = new OAuthJweServiceProxy();
proxy.setHandler(handler);
it('propagates errors thrown by the handler', async () => {
const handler = mock<OAuthJweDecryptHandler>();
handler.decryptOAuth2TokenData.mockRejectedValue(new Error('decrypt failed'));
const proxy = new OAuthJweServiceProxy();
proxy.setHandler(handler);
const result = await proxy.getDcrJweFields(false);
await expect(proxy.decryptOAuth2TokenData({ access_token: 'a' })).rejects.toThrow(
'decrypt failed',
);
expect(handler.getDcrJweFields).toHaveBeenCalledWith(false);
expect(result).toEqual(fields);
});
it('delegates with inlineJwks=true to the registered handler', async () => {
const fields = {
jwks: { keys: [{ kty: 'RSA', alg: 'RSA-OAEP-256', kid: 'kid-1' }] },
id_token_encrypted_response_alg: 'RSA-OAEP-256',
};
const handler = mock<OAuthJweHandler>();
handler.getDcrJweFields.mockResolvedValue(fields);
const proxy = new OAuthJweServiceProxy();
proxy.setHandler(handler);
const result = await proxy.getDcrJweFields(true);
expect(handler.getDcrJweFields).toHaveBeenCalledWith(true);
expect(result).toEqual(fields);
});
});
});

View File

@ -19,6 +19,7 @@ import { AuthError } from '@/errors/response-errors/auth.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { ExternalHooks } from '@/external-hooks';
import { OAuthJweServiceProxy } from '@/oauth/oauth-jwe-service.proxy';
import {
OauthService,
OauthVersion,
@ -44,6 +45,7 @@ describe('OauthService', () => {
const externalHooks = mockInstance(ExternalHooks);
const cipher = mock<Cipher>();
const dynamicCredentialsProxy = mockInstance(DynamicCredentialsProxy);
const oauthJweServiceProxy = mockInstance(OAuthJweServiceProxy);
let service: OauthService;
@ -85,6 +87,7 @@ describe('OauthService', () => {
externalHooks,
cipher,
dynamicCredentialsProxy,
oauthJweServiceProxy,
);
});
@ -1605,6 +1608,11 @@ describe('OauthService', () => {
grant_types: ['authorization_code', 'refresh_token'],
}),
);
// JWE fields are only added behind both feature gates (flag + jweEnabled).
const dcrPayload = (axios.post as jest.Mock).mock.calls[0][1];
expect(dcrPayload).not.toHaveProperty('jwks_uri');
expect(dcrPayload).not.toHaveProperty('id_token_encrypted_response_alg');
expect(dcrPayload).not.toHaveProperty('id_token_encrypted_response_enc');
expect(externalHooks.run).toHaveBeenCalledWith(
'oauth2.dynamicClientRegistration',
expect.any(Array),
@ -2801,6 +2809,131 @@ describe('OauthService', () => {
});
});
describe('generateAOauth2AuthUri with DCR and JWE fields', () => {
beforeEach(() => {
const axios = require('axios');
jest.mocked(axios.get).mockResolvedValue({
data: {
authorization_endpoint: 'https://example.domain/oauth2/auth',
token_endpoint: 'https://example.domain/oauth2/token',
registration_endpoint: 'https://example.domain/oauth2/register',
grant_types_supported: ['authorization_code', 'refresh_token'],
token_endpoint_auth_methods_supported: ['client_secret_basic'],
code_challenge_methods_supported: ['S256'],
scopes_supported: ['openid'],
},
} as any);
jest.mocked(axios.post).mockResolvedValue({
data: { client_id: 'rid', client_secret: 'rs' },
} as any);
const { ClientOAuth2 } = require('@n8n/client-oauth2');
jest.mocked(ClientOAuth2).mockImplementation(
() =>
({
code: {
getUri: jest.fn().mockReturnValue({
toString: () => 'https://example.domain/oauth2/auth?state=state',
}),
},
}) as any,
);
jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);
});
async function runDcr(
jweEnabled: boolean | undefined,
inlineJwks: boolean | undefined = undefined,
) {
const credential = mock<CredentialsEntity>({ id: '1', type: 'oAuth2Api' });
jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue({
serverUrl: 'https://example.domain',
useDynamicClientRegistration: true,
jweEnabled,
inlineJwks,
} as OAuth2CredentialData);
await service.generateAOauth2AuthUri(credential, {
cid: credential.id,
origin: 'static-credential',
userId: 'user-id',
});
const axios = require('axios');
return (axios.post as jest.Mock).mock.calls[0][1];
}
it.each([
['jweEnabled=false, inlineJwks=undefined', false, undefined],
['jweEnabled=false, inlineJwks=true', false, true],
['jweEnabled=undefined, inlineJwks=true', undefined, true],
])(
'skips the proxy entirely when the credential has not opted into JWE (%s)',
async (_label, jweEnabled, inlineJwks) => {
const payload = await runDcr(jweEnabled, inlineJwks);
expect(oauthJweServiceProxy.getDcrJweFields).not.toHaveBeenCalled();
expect(payload).not.toHaveProperty('jwks_uri');
expect(payload).not.toHaveProperty('jwks');
expect(payload).not.toHaveProperty('id_token_encrypted_response_alg');
expect(payload).not.toHaveProperty('id_token_encrypted_response_enc');
},
);
it('forwards inlineJwks to the proxy when the credential has opted in', async () => {
oauthJweServiceProxy.getDcrJweFields.mockResolvedValue({});
await runDcr(true, true);
expect(oauthJweServiceProxy.getDcrJweFields).toHaveBeenCalledWith(true);
});
it('defaults inlineJwks to false when the credential leaves it unset', async () => {
oauthJweServiceProxy.getDcrJweFields.mockResolvedValue({});
await runDcr(true, undefined);
expect(oauthJweServiceProxy.getDcrJweFields).toHaveBeenCalledWith(false);
});
it('includes jwks_uri (not jwks) when the proxy returns the URI shape', async () => {
const fields = {
jwks_uri: 'http://localhost:5678/rest/.well-known/jwks.json',
id_token_encrypted_response_alg: 'RSA-OAEP-256',
};
oauthJweServiceProxy.getDcrJweFields.mockResolvedValue(fields);
const payload = await runDcr(true, false);
expect(payload).toMatchObject(fields);
expect(payload).not.toHaveProperty('jwks');
// We deliberately leave `enc` for the IdP to choose.
expect(payload).not.toHaveProperty('id_token_encrypted_response_enc');
});
it('includes jwks (not jwks_uri) when the proxy returns the inline shape', async () => {
const fields = {
jwks: { keys: [{ kty: 'RSA', alg: 'RSA-OAEP-256', kid: 'kid-1', n: 'n', e: 'AQAB' }] },
id_token_encrypted_response_alg: 'RSA-OAEP-256',
};
oauthJweServiceProxy.getDcrJweFields.mockResolvedValue(fields);
const payload = await runDcr(true, true);
expect(payload).toMatchObject(fields);
expect(payload).not.toHaveProperty('jwks_uri');
});
it('propagates errors thrown by the proxy', async () => {
oauthJweServiceProxy.getDcrJweFields.mockRejectedValue(
new Error('OAuth JWE public key is missing an "alg" field'),
);
await expect(runDcr(true)).rejects.toThrow('OAuth JWE public key is missing an "alg" field');
});
});
describe('generateAOauth1AuthUri', () => {
it('should generate auth URI for OAuth1 credential', async () => {
const axios = require('axios');

View File

@ -1,32 +1,47 @@
import { Service } from '@n8n/di';
import type { JWK } from 'jose';
import type { IDataObject, OauthJweProxyProvider } from 'n8n-workflow';
import { UserError } from 'n8n-workflow';
export interface OAuthJweDecryptHandler {
/**
* JWE-related fields of an RFC 7591 dynamic client registration payload.
* Per RFC 7591 §2, `jwks_uri` and `jwks` are mutually exclusive. Empty
* when the JWE feature is not in play for a given registration.
*/
export type DcrJweFields = {
jwks_uri?: string;
jwks?: { keys: JWK[] };
id_token_encrypted_response_alg?: string;
};
export interface OAuthJweHandler {
decryptOAuth2TokenData(tokenData: IDataObject): Promise<IDataObject>;
getDcrJweFields(inlineJwks: boolean): Promise<DcrJweFields>;
}
/**
* CLI-side proxy for OAuth2 JWE decryption. Lives outside the `oauth-jwe`
* module so non-module callers (the OAuth2 callback controller) and the
* execution engine (via `additionalData['oauth-jwe']`) consume it without
* importing module internals. The module wires the real handler at init time
* via {@link setHandler}; callers gate on the per-credential `jweEnabled`
* flag, so reaching this proxy means decryption was explicitly requested
* a missing handler means the OAuth2 JWE feature is disabled while a stored
* credential still expects it, and we surface that loudly rather than
* silently persisting an undecrypted JWE blob.
* CLI-side proxy for the OAuth2 JWE module. Lives outside the `oauth-jwe`
* module so non-module callers (the OAuth2 callback controller, the OAuth
* service for dynamic client registration) and the execution engine
* (via `additionalData['oauth-jwe']`) consume it without importing module
* internals or knowing about the JWE feature flag.
*
* Mirrors the redaction proxy pattern in `execution-redaction-proxy.service.ts`.
*/
@Service()
export class OAuthJweServiceProxy implements OauthJweProxyProvider {
private handler?: OAuthJweDecryptHandler;
private handler?: OAuthJweHandler;
setHandler(handler: OAuthJweDecryptHandler) {
setHandler(handler: OAuthJweHandler) {
this.handler = handler;
}
/**
* Strict: callers reaching this method have confirmed
* `credential.jweEnabled === true`, so a missing handler means a
* misconfiguration we want to surface loudly rather than silently
* persisting an undecrypted JWE blob.
*/
async decryptOAuth2TokenData(tokenData: IDataObject): Promise<IDataObject> {
if (!this.handler) {
throw new UserError(
@ -35,4 +50,17 @@ export class OAuthJweServiceProxy implements OauthJweProxyProvider {
}
return await this.handler.decryptOAuth2TokenData(tokenData);
}
/**
* Lenient: returns an empty object when the JWE feature is off on this
* instance. The per-credential opt-in (`jweEnabled`) is checked by the
* caller reaching this method means the credential has asked for JWE.
* When `inlineJwks` is true the handler returns the JWKS by value
* (`jwks`) instead of by reference (`jwks_uri`) per RFC 7591 §2, for
* IdPs that cannot reach this instance's JWKS endpoint.
*/
async getDcrJweFields(inlineJwks: boolean): Promise<DcrJweFields> {
if (!this.handler) return {};
return await this.handler.getDcrJweFields(inlineJwks);
}
}

View File

@ -52,6 +52,7 @@ import {
} from './types';
import { CredentialStoreMetadata } from '@/credentials/dynamic-credential-storage.interface';
import { DynamicCredentialsProxy } from '@/credentials/dynamic-credentials-proxy';
import { OAuthJweServiceProxy } from '@/oauth/oauth-jwe-service.proxy';
export function shouldSkipAuthOnOAuthCallback() {
const value = process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK?.toLowerCase() ?? 'false';
@ -74,6 +75,7 @@ export class OauthService {
private readonly externalHooks: ExternalHooks,
private readonly cipher: Cipher,
private readonly dynamicCredentialsProxy: DynamicCredentialsProxy,
private readonly oauthJweServiceProxy: OAuthJweServiceProxy,
) {}
private validateOAuthUrlOrThrow(url: string): void {
@ -537,6 +539,9 @@ export class OauthService {
client_name: 'n8n',
client_uri: 'https://n8n.io/',
scope,
...(oauthCredentials.jweEnabled === true
? await this.oauthJweServiceProxy.getDcrJweFields(oauthCredentials.inlineJwks === true)
: {}),
};
await this.externalHooks.run('oauth2.dynamicClientRegistration', [registerPayload]);

View File

@ -142,6 +142,9 @@ describe('FrontendService', () => {
const urlService = mock<UrlService>({
getInstanceBaseUrl: jest.fn().mockReturnValue('http://localhost:5678'),
getWebhookBaseUrl: jest.fn().mockReturnValue('http://localhost:5678'),
getInstanceJwksUri: jest
.fn()
.mockReturnValue('http://localhost:5678/rest/.well-known/jwks.json'),
});
const securityConfig = mock<SecurityConfig>({

View File

@ -49,4 +49,19 @@ describe('UrlService', () => {
expect(urlService.getInstanceBaseUrl()).toBe('https://example.com');
});
});
describe('getInstanceJwksUri', () => {
it('appends the REST endpoint and JWKS path to the instance base URL', () => {
process.env.WEBHOOK_URL = undefined;
const urlService = new UrlService(
mock<GlobalConfig>({
editorBaseUrl: 'https://example.com/',
endpoints: { rest: 'rest' },
}),
);
expect(urlService.getInstanceJwksUri()).toBe(
'https://example.com/rest/.well-known/jwks.json',
);
});
});
});

View File

@ -721,7 +721,7 @@ export class FrontendService {
credential.name === 'oAuth2Api' ||
this.credentialTypes.getParentTypes(credential.name).includes('oAuth2Api');
if (isOAuth2Credential && credential.properties) {
const jwksUri = `${this.urlService.getInstanceBaseUrl()}/${this.globalConfig.endpoints.rest}/.well-known/jwks.json`;
const jwksUri = this.urlService.getInstanceJwksUri();
credential.properties = credential.properties.map((property) =>
property.name === 'jwksUri' ? { ...property, default: jwksUri } : property,
);

View File

@ -26,6 +26,11 @@ export class UrlService {
return n8nBaseUrl.endsWith('/') ? n8nBaseUrl.slice(0, n8nBaseUrl.length - 1) : n8nBaseUrl;
}
/** Returns the absolute URL of this instance's JWKS endpoint. */
getInstanceJwksUri(): string {
return `${this.getInstanceBaseUrl()}/${this.globalConfig.endpoints.rest}/.well-known/jwks.json`;
}
private generateBaseUrl(): string {
const { path, port, host, protocol } = this.globalConfig;

View File

@ -235,5 +235,23 @@ export class OAuth2Api implements ICredentialType {
},
doNotInherit: true,
},
{
// Transitively gated by `envFeatureFlag: 'OAUTH2_JWE'` on
// `jweEnabled`: when the flag is off, `jweEnabled` is hidden, so this
// toggle's `displayOptions.show: jweEnabled: [true]` can never match.
displayName: 'Inline JWKS in Client Registration',
name: 'inlineJwks',
type: 'boolean',
default: false,
description:
'Whether to send the public keys directly in the dynamic client registration payload instead of advertising a JWKS URI. Enable this when the IdP cannot reach this instance (e.g. when self-hosted behind a firewall).',
displayOptions: {
show: {
jweEnabled: [true],
useDynamicClientRegistration: [true],
},
},
doNotInherit: true,
},
];
}