fix(Salesforce Node): Allow overriding JWT audience with My Domain URL (#29016)

This commit is contained in:
Jean Ibarz
2026-05-04 07:53:09 +00:00
committed by GitHub
parent b4d898e4ae
commit 9decb1e2a9
4 changed files with 274 additions and 10 deletions
@@ -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:<br />-----BEGIN PRIVATE KEY-----<br />KEY DATA GOES HERE<br />-----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. <code>https://mycompany.my.salesforce.com</code>). 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<IHttpRequestOptions> {
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';
}
@@ -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');
});
});
});
@@ -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<IDataObject> {
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(
{
@@ -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',