mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-21 12:51:16 +08:00
fix(Salesforce Node): Allow overriding JWT audience with My Domain URL (#29016)
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user