mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-30 08:17:06 +02:00
feat(core): Inline JWKS in OAuth2 dynamic client registration (#29986)
This commit is contained in:
parent
8026438d97
commit
a4ff8358e1
|
|
@ -23,6 +23,7 @@ export interface OAuth2CredentialData {
|
|||
useDynamicClientRegistration?: boolean;
|
||||
serverUrl?: string;
|
||||
jweEnabled?: boolean;
|
||||
inlineJwks?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(', ')})` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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>({
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user