From 9decb1e2a9f6d6612014354d7ca6f8b62600ce9d Mon Sep 17 00:00:00 2001 From: Jean Ibarz Date: Mon, 4 May 2026 09:53:09 +0200 Subject: [PATCH] fix(Salesforce Node): Allow overriding JWT audience with My Domain URL (#29016) --- .../SalesforceJwtApi.credentials.ts | 27 ++- .../test/SalesforceJwtApi.credentials.test.ts | 211 ++++++++++++++++++ .../nodes/Salesforce/GenericFunctions.ts | 10 +- .../__test__/GenericFunctions.test.ts | 36 +++ 4 files changed, 274 insertions(+), 10 deletions(-) create mode 100644 packages/nodes-base/credentials/test/SalesforceJwtApi.credentials.test.ts diff --git a/packages/nodes-base/credentials/SalesforceJwtApi.credentials.ts b/packages/nodes-base/credentials/SalesforceJwtApi.credentials.ts index a2262c45d48..d97ef162752 100644 --- a/packages/nodes-base/credentials/SalesforceJwtApi.credentials.ts +++ b/packages/nodes-base/credentials/SalesforceJwtApi.credentials.ts @@ -2,6 +2,7 @@ import type { AxiosRequestConfig } from 'axios'; import axios from 'axios'; import jwt from 'jsonwebtoken'; import moment from 'moment-timezone'; + import { formatPrivateKey } from '@utils/utilities'; import type { ICredentialDataDecryptedObject, @@ -63,6 +64,15 @@ export class SalesforceJwtApi implements ICredentialType { description: 'Use the multiline editor. Make sure it is in standard PEM key format:
-----BEGIN PRIVATE KEY-----
KEY DATA GOES HERE
-----END PRIVATE KEY-----', }, + { + displayName: 'My Domain URL', + name: 'myDomainUrl', + type: 'string', + default: '', + placeholder: 'https://mycompany.my.salesforce.com', + description: + "Your org's My Domain URL (e.g. https://mycompany.my.salesforce.com). Required for Spring '26 and later orgs; leave blank to keep the default audience used by earlier orgs.", + }, ]; async authenticate( @@ -70,10 +80,7 @@ export class SalesforceJwtApi implements ICredentialType { requestOptions: IHttpRequestOptions, ): Promise { const now = moment().unix(); - const authUrl = - credentials.environment === 'sandbox' - ? 'https://test.salesforce.com' - : 'https://login.salesforce.com'; + const authUrl = resolveAuthUrl(credentials); const privateKey = formatPrivateKey(credentials.privateKey as string); const signature = jwt.sign( { @@ -118,9 +125,19 @@ export class SalesforceJwtApi implements ICredentialType { test: ICredentialTestRequest = { request: { baseURL: - '={{$credentials?.environment === "sandbox" ? "https://test.salesforce.com" : "https://login.salesforce.com"}}', + '={{$credentials?.myDomainUrl ? $credentials.myDomainUrl.replace(/\\/$/, "") : ($credentials?.environment === "sandbox" ? "https://test.salesforce.com" : "https://login.salesforce.com")}}', url: '/services/oauth2/userinfo', method: 'GET', }, }; } + +export function resolveAuthUrl(credentials: ICredentialDataDecryptedObject): string { + const myDomainUrl = ((credentials.myDomainUrl as string | undefined) ?? '').replace(/\/$/, ''); + if (myDomainUrl) { + return myDomainUrl; + } + return credentials.environment === 'sandbox' + ? 'https://test.salesforce.com' + : 'https://login.salesforce.com'; +} diff --git a/packages/nodes-base/credentials/test/SalesforceJwtApi.credentials.test.ts b/packages/nodes-base/credentials/test/SalesforceJwtApi.credentials.test.ts new file mode 100644 index 00000000000..f1201c69067 --- /dev/null +++ b/packages/nodes-base/credentials/test/SalesforceJwtApi.credentials.test.ts @@ -0,0 +1,211 @@ +import axios from 'axios'; +import jwt from 'jsonwebtoken'; +import type { IHttpRequestOptions } from 'n8n-workflow'; + +import { SalesforceJwtApi, resolveAuthUrl } from '../SalesforceJwtApi.credentials'; + +jest.mock('axios'); +jest.mock('jsonwebtoken', () => ({ + sign: jest.fn(), +})); +jest.mock('@utils/utilities', () => ({ + formatPrivateKey: (key: string) => key, +})); + +describe('SalesforceJwtApi Credential', () => { + const credential = new SalesforceJwtApi(); + const mockedAxios = axios as unknown as jest.Mock; + const mockedSign = jwt.sign as unknown as jest.Mock; + + const baseCredentials = { + clientId: 'connected-app-client-id', + username: 'user@example.com', + privateKey: '-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----', + }; + + const requestOptions: IHttpRequestOptions = { + headers: {}, + method: 'GET', + url: 'https://login.salesforce.com/services/data/v59.0', + }; + + beforeEach(() => { + mockedAxios.mockReset(); + mockedAxios.mockResolvedValue({ data: { access_token: 'abc123' } }); + mockedSign.mockReset(); + mockedSign.mockReturnValue('signed-jwt'); + }); + + it('should have correct properties', () => { + expect(credential.name).toBe('salesforceJwtApi'); + expect(credential.displayName).toBe('Salesforce JWT API'); + expect(credential.documentationUrl).toBe('salesforce'); + expect(credential.test.request.baseURL).toBe( + '={{$credentials?.myDomainUrl ? $credentials.myDomainUrl.replace(/\\/$/, "") : ($credentials?.environment === "sandbox" ? "https://test.salesforce.com" : "https://login.salesforce.com")}}', + ); + expect(credential.test.request.url).toBe('/services/oauth2/userinfo'); + }); + + describe('resolveAuthUrl', () => { + it('defaults to login.salesforce.com for production when My Domain URL is empty', () => { + expect( + resolveAuthUrl({ ...baseCredentials, environment: 'production', myDomainUrl: '' }), + ).toBe('https://login.salesforce.com'); + }); + + it('defaults to test.salesforce.com for sandbox when My Domain URL is empty', () => { + expect(resolveAuthUrl({ ...baseCredentials, environment: 'sandbox', myDomainUrl: '' })).toBe( + 'https://test.salesforce.com', + ); + }); + + it('falls back to the default when My Domain URL is missing', () => { + expect(resolveAuthUrl({ ...baseCredentials, environment: 'production' })).toBe( + 'https://login.salesforce.com', + ); + }); + + it('uses the My Domain URL override when provided', () => { + expect( + resolveAuthUrl({ + ...baseCredentials, + environment: 'production', + myDomainUrl: 'https://acme.my.salesforce.com', + }), + ).toBe('https://acme.my.salesforce.com'); + }); + + it('strips a trailing slash from the My Domain URL', () => { + expect( + resolveAuthUrl({ + ...baseCredentials, + environment: 'sandbox', + myDomainUrl: 'https://acme--sandbox.sandbox.my.salesforce.com/', + }), + ).toBe('https://acme--sandbox.sandbox.my.salesforce.com'); + }); + + it('prefers the My Domain URL over the environment setting', () => { + expect( + resolveAuthUrl({ + ...baseCredentials, + environment: 'sandbox', + myDomainUrl: 'https://acme.my.salesforce.com', + }), + ).toBe('https://acme.my.salesforce.com'); + }); + }); + + describe('authenticate', () => { + it('signs the JWT with the sandbox default audience when no My Domain URL is set', async () => { + await credential.authenticate( + { ...baseCredentials, environment: 'sandbox', myDomainUrl: '' }, + requestOptions, + ); + + expect(mockedSign).toHaveBeenCalledWith( + expect.objectContaining({ aud: 'https://test.salesforce.com' }), + expect.any(String), + expect.any(Object), + ); + expect(mockedAxios).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://test.salesforce.com/services/oauth2/token', + }), + ); + }); + + it('signs the JWT with the production default audience when no My Domain URL is set', async () => { + await credential.authenticate( + { ...baseCredentials, environment: 'production', myDomainUrl: '' }, + requestOptions, + ); + + expect(mockedSign).toHaveBeenCalledWith( + expect.objectContaining({ aud: 'https://login.salesforce.com' }), + expect.any(String), + expect.any(Object), + ); + expect(mockedAxios).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://login.salesforce.com/services/oauth2/token', + }), + ); + }); + + it("signs the JWT with the Spring '26 sandbox My Domain URL as audience (issue #28990)", async () => { + await credential.authenticate( + { + ...baseCredentials, + environment: 'sandbox', + myDomainUrl: 'https://acme--sandbox.sandbox.my.salesforce.com', + }, + requestOptions, + ); + + expect(mockedSign).toHaveBeenCalledWith( + expect.objectContaining({ aud: 'https://acme--sandbox.sandbox.my.salesforce.com' }), + expect.any(String), + expect.any(Object), + ); + expect(mockedAxios).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://acme--sandbox.sandbox.my.salesforce.com/services/oauth2/token', + }), + ); + }); + + it('signs the JWT with a production My Domain URL as audience', async () => { + await credential.authenticate( + { + ...baseCredentials, + environment: 'production', + myDomainUrl: 'https://acme.my.salesforce.com', + }, + requestOptions, + ); + + expect(mockedSign).toHaveBeenCalledWith( + expect.objectContaining({ aud: 'https://acme.my.salesforce.com' }), + expect.any(String), + expect.any(Object), + ); + expect(mockedAxios).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://acme.my.salesforce.com/services/oauth2/token', + }), + ); + }); + + it('normalizes a trailing slash in the My Domain URL before signing', async () => { + await credential.authenticate( + { + ...baseCredentials, + environment: 'sandbox', + myDomainUrl: 'https://acme--sandbox.sandbox.my.salesforce.com/', + }, + requestOptions, + ); + + expect(mockedSign).toHaveBeenCalledWith( + expect.objectContaining({ aud: 'https://acme--sandbox.sandbox.my.salesforce.com' }), + expect.any(String), + expect.any(Object), + ); + expect(mockedAxios).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://acme--sandbox.sandbox.my.salesforce.com/services/oauth2/token', + }), + ); + }); + + it('attaches the returned access token to the outgoing request', async () => { + const result = await credential.authenticate( + { ...baseCredentials, environment: 'production', myDomainUrl: '' }, + requestOptions, + ); + + expect(result.headers?.Authorization).toBe('Bearer abc123'); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts b/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts index 78c52bfc3bb..54f50b06e4a 100644 --- a/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts @@ -4,6 +4,7 @@ import { DateTime } from 'luxon'; import type { IExecuteFunctions, ILoadOptionsFunctions, + ICredentialDataDecryptedObject, IDataObject, INodePropertyOptions, JsonObject, @@ -13,6 +14,8 @@ import type { } from 'n8n-workflow'; import { NodeApiError } from 'n8n-workflow'; +import { resolveAuthUrl } from '../../credentials/SalesforceJwtApi.credentials'; + function getOptions( this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions, method: IHttpRequestMethods, @@ -42,13 +45,10 @@ function getOptions( async function getAccessToken( this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions, - credentials: IDataObject, + credentials: ICredentialDataDecryptedObject, ): Promise { const now = moment().unix(); - const authUrl = - credentials.environment === 'sandbox' - ? 'https://test.salesforce.com' - : 'https://login.salesforce.com'; + const authUrl = resolveAuthUrl(credentials); const signature = jwt.sign( { diff --git a/packages/nodes-base/nodes/Salesforce/__test__/GenericFunctions.test.ts b/packages/nodes-base/nodes/Salesforce/__test__/GenericFunctions.test.ts index 762c946d73b..537dba143f7 100644 --- a/packages/nodes-base/nodes/Salesforce/__test__/GenericFunctions.test.ts +++ b/packages/nodes-base/nodes/Salesforce/__test__/GenericFunctions.test.ts @@ -622,6 +622,42 @@ describe('Salesforce -> GenericFunctions', () => { }); }); + it("should use the My Domain URL as JWT audience and token endpoint when set (Spring '26)", async () => { + const mockCredentials = { + clientId: 'test-client-id', + username: 'test@example.com', + privateKey: 'mock-private-key', + environment: 'sandbox', + myDomainUrl: 'https://acme--sandbox.sandbox.my.salesforce.com', + }; + const mockResponse = { + access_token: 'my-domain-access-token', + instance_url: 'https://acme--sandbox.sandbox.my.salesforce.com', + }; + + mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials); + mockRequest.mockResolvedValue(mockResponse); + mockExecuteFunctions.logger = { + debug: jest.fn(), + } as any; + + await salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {}); + + expect(mockJwt.sign as jest.Mock).toHaveBeenCalledWith( + expect.objectContaining({ + aud: 'https://acme--sandbox.sandbox.my.salesforce.com', + }), + 'mock-private-key', + expect.any(Object), + ); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + uri: 'https://acme--sandbox.sandbox.my.salesforce.com/services/oauth2/token', + }), + ); + }); + it('should handle JWT token exchange with body and query parameters', async () => { const mockCredentials = { clientId: 'test-client-id',