diff --git a/packages/@n8n/client-oauth2/src/types.ts b/packages/@n8n/client-oauth2/src/types.ts index e5272dbf776..72385d0b3f3 100644 --- a/packages/@n8n/client-oauth2/src/types.ts +++ b/packages/@n8n/client-oauth2/src/types.ts @@ -23,6 +23,7 @@ export interface OAuth2CredentialData { useDynamicClientRegistration?: boolean; serverUrl?: string; jweEnabled?: boolean; + inlineJwks?: boolean; } /** diff --git a/packages/cli/src/modules/oauth-jwe/__tests__/oauth-jwe-decrypt.service.test.ts b/packages/cli/src/modules/oauth-jwe/__tests__/oauth-jwe-decrypt.service.test.ts index c854ae5c938..e03e803c997 100644 --- a/packages/cli/src/modules/oauth-jwe/__tests__/oauth-jwe-decrypt.service.test.ts +++ b/packages/cli/src/modules/oauth-jwe/__tests__/oauth-jwe-decrypt.service.test.ts @@ -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()); }); 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 = PUBLIC_JWK) { + const keyService = mock(); + keyService.getPublicJwk.mockResolvedValue(jwk); + const urlService = mock(); + 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(); + keyService.getPublicJwk.mockResolvedValue({ + kty: 'RSA', + kid: 'kid-no-alg', + n: 'n', + e: 'AQAB', + }); + const localService = new OAuthJweDecryptService(keyService, mock()); + + await expect(localService.getDcrJweFields(false)).rejects.toThrow(UnexpectedError); + await expect(localService.getDcrJweFields(false)).rejects.toThrow( + 'OAuth JWE public key is missing an "alg" field', + ); + }); + }); }); diff --git a/packages/cli/src/modules/oauth-jwe/__tests__/oauth-jwe.utils.test.ts b/packages/cli/src/modules/oauth-jwe/__tests__/oauth-jwe.utils.test.ts index b29c2ad3464..6e84276da70 100644 --- a/packages/cli/src/modules/oauth-jwe/__tests__/oauth-jwe.utils.test.ts +++ b/packages/cli/src/modules/oauth-jwe/__tests__/oauth-jwe.utils.test.ts @@ -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', () => { diff --git a/packages/cli/src/modules/oauth-jwe/oauth-jwe-decrypt.service.ts b/packages/cli/src/modules/oauth-jwe/oauth-jwe-decrypt.service.ts index 92bc2c04b8c..66f93cf12a5 100644 --- a/packages/cli/src/modules/oauth-jwe/oauth-jwe-decrypt.service.ts +++ b/packages/cli/src/modules/oauth-jwe/oauth-jwe-decrypt.service.ts @@ -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 { + 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 = inlineJwks + ? { jwks: { keys: [publicJwk] } } + : { jwks_uri: this.urlService.getInstanceJwksUri() }; + return { + ...keyDistribution, + id_token_encrypted_response_alg: publicJwk.alg, + }; + } async decryptOAuth2TokenData(tokenData: IDataObject): Promise { const { privateKey } = await this.keyService.getKeyPair(); diff --git a/packages/cli/src/modules/oauth-jwe/oauth-jwe.utils.ts b/packages/cli/src/modules/oauth-jwe/oauth-jwe.utils.ts index 16b47f2a247..7b96904d685 100644 --- a/packages/cli/src/modules/oauth-jwe/oauth-jwe.utils.ts +++ b/packages/cli/src/modules/oauth-jwe/oauth-jwe.utils.ts @@ -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 { - 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(', ')})` : ''; } /** diff --git a/packages/cli/src/oauth/__tests__/oauth-jwe-service.proxy.test.ts b/packages/cli/src/oauth/__tests__/oauth-jwe-service.proxy.test.ts index 6ea467d1ef0..eae7cab0b9b 100644 --- a/packages/cli/src/oauth/__tests__/oauth-jwe-service.proxy.test.ts +++ b/packages/cli/src/oauth/__tests__/oauth-jwe-service.proxy.test.ts @@ -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(); + 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(); + 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(); - 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(); + handler.getDcrJweFields.mockResolvedValue(fields); + const proxy = new OAuthJweServiceProxy(); + proxy.setHandler(handler); - it('propagates errors thrown by the handler', async () => { - const handler = mock(); - 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(); + 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); + }); }); }); diff --git a/packages/cli/src/oauth/__tests__/oauth.service.test.ts b/packages/cli/src/oauth/__tests__/oauth.service.test.ts index 2371aab5c33..6bc12a0bbc9 100644 --- a/packages/cli/src/oauth/__tests__/oauth.service.test.ts +++ b/packages/cli/src/oauth/__tests__/oauth.service.test.ts @@ -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(); 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({ 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'); diff --git a/packages/cli/src/oauth/oauth-jwe-service.proxy.ts b/packages/cli/src/oauth/oauth-jwe-service.proxy.ts index 60aa77dcb34..a242be18eb4 100644 --- a/packages/cli/src/oauth/oauth-jwe-service.proxy.ts +++ b/packages/cli/src/oauth/oauth-jwe-service.proxy.ts @@ -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; + getDcrJweFields(inlineJwks: boolean): Promise; } /** - * 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 { 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 { + if (!this.handler) return {}; + return await this.handler.getDcrJweFields(inlineJwks); + } } diff --git a/packages/cli/src/oauth/oauth.service.ts b/packages/cli/src/oauth/oauth.service.ts index b2baf57af90..90b8cce17ee 100644 --- a/packages/cli/src/oauth/oauth.service.ts +++ b/packages/cli/src/oauth/oauth.service.ts @@ -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]); diff --git a/packages/cli/src/services/__tests__/frontend.service.test.ts b/packages/cli/src/services/__tests__/frontend.service.test.ts index a9655168036..84ab71e8b40 100644 --- a/packages/cli/src/services/__tests__/frontend.service.test.ts +++ b/packages/cli/src/services/__tests__/frontend.service.test.ts @@ -142,6 +142,9 @@ describe('FrontendService', () => { const urlService = mock({ 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({ diff --git a/packages/cli/src/services/__tests__/url.service.test.ts b/packages/cli/src/services/__tests__/url.service.test.ts index 7e4c680cd0d..b603225b5af 100644 --- a/packages/cli/src/services/__tests__/url.service.test.ts +++ b/packages/cli/src/services/__tests__/url.service.test.ts @@ -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({ + editorBaseUrl: 'https://example.com/', + endpoints: { rest: 'rest' }, + }), + ); + expect(urlService.getInstanceJwksUri()).toBe( + 'https://example.com/rest/.well-known/jwks.json', + ); + }); + }); }); diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 65bda25a980..20745410a86 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -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, ); diff --git a/packages/cli/src/services/url.service.ts b/packages/cli/src/services/url.service.ts index 717b5e85d02..0154135da47 100644 --- a/packages/cli/src/services/url.service.ts +++ b/packages/cli/src/services/url.service.ts @@ -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; diff --git a/packages/nodes-base/credentials/OAuth2Api.credentials.ts b/packages/nodes-base/credentials/OAuth2Api.credentials.ts index fa88157cf85..2899200ae69 100644 --- a/packages/nodes-base/credentials/OAuth2Api.credentials.ts +++ b/packages/nodes-base/credentials/OAuth2Api.credentials.ts @@ -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, + }, ]; }