fix(Salesforce Node): Reuse JWT session token across requests (#32325)

This commit is contained in:
Bernhard Wittmann
2026-06-19 07:40:25 +02:00
committed by GitHub
parent 2c3c67f3da
commit 0e4d2c3bdb
7 changed files with 574 additions and 579 deletions
@@ -18,6 +18,7 @@ import type {
IAuthenticateGeneric,
ICredentialDataDecryptedObject,
ICredentialType,
IHttpRequestHelper,
IHttpRequestOptions,
INode,
INodeProperties,
@@ -26,6 +27,14 @@ import type {
IWorkflowExecuteAdditionalData,
} from 'n8n-workflow';
import { deepCopy, Workflow } from 'n8n-workflow';
import { generateKeyPairSync } from 'node:crypto';
import { SalesforceJwtApi } from 'n8n-nodes-base/credentials/SalesforceJwtApi.credentials';
// The credential module resolves to nodes-base source, which uses a package-internal
// path alias not mapped by cli's jest config.
jest.mock('@utils/utilities', () => ({ formatPrivateKey: (key: string) => key }), {
virtual: true,
});
import { CredentialTypes } from '@/credential-types';
import { DynamicCredentialsProxy } from '@/credentials/dynamic-credentials-proxy';
@@ -1555,4 +1564,138 @@ describe('CredentialsHelper', () => {
expect(resultB.apiKey).toBe('key_account_B_UPDATED');
});
});
describe('preAuthentication token caching', () => {
// Proves the framework performs the JWT login only when the cached token is
// missing or expired, so chained Salesforce actions reuse one session instead
// of authenticating on every request. The login is the credential's
// `preAuthentication` hook, which we observe through a counting `httpRequest`.
const { privateKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
const salesforceJwt = new SalesforceJwtApi();
const node: INode = {
id: 'uuid-sf',
name: 'Salesforce',
type: 'n8n-nodes-base.salesforce',
typeVersion: 1,
position: [0, 0],
parameters: {},
credentials: { salesforceJwtApi: { id: 'sf-cred', name: 'Salesforce JWT' } },
};
let httpRequest: jest.Mock;
let helpers: IHttpRequestHelper;
let updateSpy: jest.SpyInstance;
let credentials: ICredentialDataDecryptedObject;
beforeEach(() => {
jest.clearAllMocks();
mockNodesAndCredentials.getCredential
.calledWith('salesforceJwtApi')
.mockReturnValue({ type: salesforceJwt, sourcePath: '' });
let logins = 0;
// eslint-disable-next-line @typescript-eslint/require-await
httpRequest = jest.fn().mockImplementation(async () => ({
access_token: `TOKEN_${++logins}`,
instance_url: 'https://acme.my.salesforce.com',
}));
helpers = { helpers: { httpRequest } } as unknown as IHttpRequestHelper;
// Stub persistence so the test does not touch the DB. The returned token is
// what the request helper merges back into the in-memory credentials for the
// next request (see httpRequestWithAuthentication).
updateSpy = jest.spyOn(credentialsHelper, 'updateCredentials').mockResolvedValue();
credentials = {
accessToken: '',
instanceUrl: '',
clientId: 'connected-app-client-id',
username: 'user@example.com',
privateKey,
environment: 'production',
};
});
afterEach(() => updateSpy.mockRestore());
test('logs in once and reuses the cached token across requests', async () => {
// Request 1: no cached token → exactly one login.
const first = await credentialsHelper.preAuthentication(
helpers,
credentials,
'salesforceJwtApi',
node,
false,
);
expect(httpRequest).toHaveBeenCalledTimes(1);
expect(httpRequest).toHaveBeenCalledWith(
expect.objectContaining({
method: 'POST',
url: 'https://login.salesforce.com/services/oauth2/token',
}),
);
expect(first).toMatchObject({
accessToken: 'TOKEN_1',
instanceUrl: 'https://acme.my.salesforce.com',
});
// The token would be persisted so the next request reads it back.
expect(updateSpy).toHaveBeenCalledTimes(1);
// preAuthentication merges the token into the in-memory credentials, exactly
// as the request helper does before the next request.
Object.assign(credentials, first);
// Requests 2 and 3 already have a token → no further logins.
const second = await credentialsHelper.preAuthentication(
helpers,
credentials,
'salesforceJwtApi',
node,
false,
);
const third = await credentialsHelper.preAuthentication(
helpers,
credentials,
'salesforceJwtApi',
node,
false,
);
expect(second).toBeUndefined();
expect(third).toBeUndefined();
// Still only the single login from request 1 across all three requests.
expect(httpRequest).toHaveBeenCalledTimes(1);
});
test('re-authenticates when the cached token is reported expired (e.g. after a 401)', async () => {
await credentialsHelper.preAuthentication(
helpers,
credentials,
'salesforceJwtApi',
node,
false,
);
expect(httpRequest).toHaveBeenCalledTimes(1);
expect(credentials.accessToken).toBe('TOKEN_1');
// credentialsExpired = true mirrors the request helper's retry after a 401.
const refreshed = await credentialsHelper.preAuthentication(
helpers,
credentials,
'salesforceJwtApi',
node,
true,
);
expect(httpRequest).toHaveBeenCalledTimes(2);
expect(refreshed).toMatchObject({ accessToken: 'TOKEN_2' });
});
});
});
@@ -1,5 +1,3 @@
import type { AxiosRequestConfig } from 'axios';
import axios from 'axios';
import jwt from 'jsonwebtoken';
import moment from 'moment-timezone';
@@ -8,9 +6,11 @@ import type {
ICredentialDataDecryptedObject,
ICredentialTestRequest,
ICredentialType,
IHttpRequestHelper,
IHttpRequestOptions,
INodeProperties,
} from 'n8n-workflow';
import { OperationalError } from 'n8n-workflow';
export class SalesforceJwtApi implements ICredentialType {
name = 'salesforceJwtApi';
@@ -20,6 +20,21 @@ export class SalesforceJwtApi implements ICredentialType {
documentationUrl = 'salesforce';
properties: INodeProperties[] = [
{
displayName: 'Access Token',
name: 'accessToken',
type: 'hidden',
typeOptions: {
expirable: true,
},
default: '',
},
{
displayName: 'Instance URL',
name: 'instanceUrl',
type: 'hidden',
default: '',
},
{
displayName: 'Environment Type',
name: 'environment',
@@ -75,10 +90,11 @@ export class SalesforceJwtApi implements ICredentialType {
},
];
async authenticate(
credentials: ICredentialDataDecryptedObject,
requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
// Only called when "accessToken" (the expirable property) is empty or expired.
// Exchanges the signed JWT for an access token once and caches it (together with
// the instance URL) so chained Salesforce actions reuse the same session instead
// of logging in on every request.
async preAuthentication(this: IHttpRequestHelper, credentials: ICredentialDataDecryptedObject) {
const now = moment().unix();
const authUrl = resolveAuthUrl(credentials);
const privateKey = formatPrivateKey(credentials.privateKey as string);
@@ -98,28 +114,44 @@ export class SalesforceJwtApi implements ICredentialType {
},
);
const axiosRequestConfig: AxiosRequestConfig = {
const response = (await this.helpers.httpRequest({
method: 'POST',
url: `${authUrl}/services/oauth2/token`,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
method: 'POST',
data: new URLSearchParams({
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: signature,
}).toString(),
url: `${authUrl}/services/oauth2/token`,
responseType: 'json',
};
const result = await axios(axiosRequestConfig);
const { access_token } = result.data as { access_token: string };
})) as { access_token?: string; instance_url?: string };
return {
...requestOptions,
headers: {
...requestOptions.headers,
Authorization: `Bearer ${access_token}`,
},
if (!response.access_token || !response.instance_url) {
throw new OperationalError(
'Salesforce JWT authentication did not return an access token and instance URL',
);
}
return { accessToken: response.access_token, instanceUrl: response.instance_url };
}
async authenticate(
credentials: ICredentialDataDecryptedObject,
requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
requestOptions.headers = {
...requestOptions.headers,
Authorization: `Bearer ${credentials.accessToken as string}`,
};
// Node requests pass a relative URL and rely on the cached instance URL as base.
// The credential test supplies its own baseURL (the login/My Domain URL), which
// must be left untouched.
if (!requestOptions.baseURL && credentials.instanceUrl) {
requestOptions.baseURL = credentials.instanceUrl as string;
}
return requestOptions;
}
test: ICredentialTestRequest = {
@@ -1,11 +1,13 @@
import axios from 'axios';
import jwt from 'jsonwebtoken';
import type { IHttpRequestOptions } from 'n8n-workflow';
import type {
ICredentialDataDecryptedObject,
IHttpRequestHelper,
INodeProperties,
} from 'n8n-workflow';
import { SalesforceJwtApi, resolveAuthUrl } from '../SalesforceJwtApi.credentials';
import type { Mock } from 'vitest';
vi.mock('axios');
vi.mock('jsonwebtoken', () => ({
default: { sign: vi.fn() },
}));
@@ -15,8 +17,9 @@ vi.mock('@utils/utilities', () => ({
describe('SalesforceJwtApi Credential', () => {
const credential = new SalesforceJwtApi();
const mockedAxios = axios as unknown as Mock;
const mockedSign = jwt.sign as unknown as Mock;
const mockHttpRequest = vi.fn();
const helpers = { helpers: { httpRequest: mockHttpRequest } } as unknown as IHttpRequestHelper;
const baseCredentials = {
clientId: 'connected-app-client-id',
@@ -24,15 +27,12 @@ describe('SalesforceJwtApi Credential', () => {
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' } });
mockHttpRequest.mockReset();
mockHttpRequest.mockResolvedValue({
access_token: 'abc123',
instance_url: 'https://acme.my.salesforce.com',
});
mockedSign.mockReset();
mockedSign.mockReturnValue('signed-jwt');
});
@@ -47,6 +47,19 @@ describe('SalesforceJwtApi Credential', () => {
expect(credential.test.request.url).toBe('/services/oauth2/userinfo');
});
it('caches the access token in an expirable hidden field', () => {
const accessToken = credential.properties.find(
(property: INodeProperties) => property.name === 'accessToken',
);
expect(accessToken?.type).toBe('hidden');
expect(accessToken?.typeOptions?.expirable).toBe(true);
const instanceUrl = credential.properties.find(
(property: INodeProperties) => property.name === 'instanceUrl',
);
expect(instanceUrl?.type).toBe('hidden');
});
describe('resolveAuthUrl', () => {
it('defaults to login.salesforce.com for production when My Domain URL is empty', () => {
expect(
@@ -97,37 +110,39 @@ describe('SalesforceJwtApi Credential', () => {
});
});
describe('authenticate', () => {
describe('preAuthentication', () => {
const callPreAuthentication = async (credentials: ICredentialDataDecryptedObject) =>
await credential.preAuthentication.call(helpers, credentials);
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,
);
await callPreAuthentication({ ...baseCredentials, environment: 'sandbox', myDomainUrl: '' });
expect(mockedSign).toHaveBeenCalledWith(
expect.objectContaining({ aud: 'https://test.salesforce.com' }),
expect.any(String),
expect.any(Object),
);
expect(mockedAxios).toHaveBeenCalledWith(
expect(mockHttpRequest).toHaveBeenCalledWith(
expect.objectContaining({
method: 'POST',
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,
);
await callPreAuthentication({
...baseCredentials,
environment: 'production',
myDomainUrl: '',
});
expect(mockedSign).toHaveBeenCalledWith(
expect.objectContaining({ aud: 'https://login.salesforce.com' }),
expect.any(String),
expect.any(Object),
);
expect(mockedAxios).toHaveBeenCalledWith(
expect(mockHttpRequest).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://login.salesforce.com/services/oauth2/token',
}),
@@ -135,21 +150,18 @@ describe('SalesforceJwtApi Credential', () => {
});
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,
);
await callPreAuthentication({
...baseCredentials,
environment: 'sandbox',
myDomainUrl: 'https://acme--sandbox.sandbox.my.salesforce.com',
});
expect(mockedSign).toHaveBeenCalledWith(
expect.objectContaining({ aud: 'https://acme--sandbox.sandbox.my.salesforce.com' }),
expect.any(String),
expect.any(Object),
);
expect(mockedAxios).toHaveBeenCalledWith(
expect(mockHttpRequest).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://acme--sandbox.sandbox.my.salesforce.com/services/oauth2/token',
}),
@@ -157,21 +169,18 @@ describe('SalesforceJwtApi Credential', () => {
});
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,
);
await callPreAuthentication({
...baseCredentials,
environment: 'production',
myDomainUrl: 'https://acme.my.salesforce.com',
});
expect(mockedSign).toHaveBeenCalledWith(
expect.objectContaining({ aud: 'https://acme.my.salesforce.com' }),
expect.any(String),
expect.any(Object),
);
expect(mockedAxios).toHaveBeenCalledWith(
expect(mockHttpRequest).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://acme.my.salesforce.com/services/oauth2/token',
}),
@@ -179,34 +188,90 @@ describe('SalesforceJwtApi Credential', () => {
});
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,
);
await callPreAuthentication({
...baseCredentials,
environment: 'sandbox',
myDomainUrl: 'https://acme--sandbox.sandbox.my.salesforce.com/',
});
expect(mockedSign).toHaveBeenCalledWith(
expect.objectContaining({ aud: 'https://acme--sandbox.sandbox.my.salesforce.com' }),
expect.any(String),
expect.any(Object),
);
expect(mockedAxios).toHaveBeenCalledWith(
expect(mockHttpRequest).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,
);
it('returns the access token and instance URL to cache', async () => {
mockHttpRequest.mockResolvedValueOnce({
access_token: 'cached-token',
instance_url: 'https://acme.my.salesforce.com',
});
expect(result.headers?.Authorization).toBe('Bearer abc123');
const result = await callPreAuthentication({
...baseCredentials,
environment: 'production',
myDomainUrl: '',
});
expect(result).toEqual({
accessToken: 'cached-token',
instanceUrl: 'https://acme.my.salesforce.com',
});
});
it('throws when the token response is missing the access token or instance URL', async () => {
mockHttpRequest.mockResolvedValueOnce({ access_token: 'cached-token' });
await expect(
callPreAuthentication({ ...baseCredentials, environment: 'production', myDomainUrl: '' }),
).rejects.toThrow('Salesforce JWT authentication did not return an access token');
});
});
describe('authenticate', () => {
const credentials = {
...baseCredentials,
accessToken: 'cached-token',
instanceUrl: 'https://acme.my.salesforce.com',
};
it('attaches the cached bearer token and resolves the instance URL for node requests', async () => {
const result = await credential.authenticate(credentials, {
headers: {},
method: 'GET',
url: '/services/data/v59.0/sobjects/Account/describe',
});
expect(result.headers?.Authorization).toBe('Bearer cached-token');
expect(result.baseURL).toBe('https://acme.my.salesforce.com');
});
it('does not overwrite a baseURL provided by the caller (credential test)', async () => {
const result = await credential.authenticate(credentials, {
headers: {},
method: 'GET',
baseURL: 'https://login.salesforce.com',
url: '/services/oauth2/userinfo',
});
expect(result.headers?.Authorization).toBe('Bearer cached-token');
expect(result.baseURL).toBe('https://login.salesforce.com');
});
it('does not perform a token exchange', async () => {
await credential.authenticate(credentials, {
headers: {},
method: 'GET',
url: '/services/data/v59.0/sobjects/Account/describe',
});
expect(mockHttpRequest).not.toHaveBeenCalled();
expect(mockedSign).not.toHaveBeenCalled();
});
});
});
@@ -1,21 +1,17 @@
import jwt from 'jsonwebtoken';
import moment from 'moment-timezone';
import { DateTime } from 'luxon';
import type {
IExecuteFunctions,
ILoadOptionsFunctions,
ICredentialDataDecryptedObject,
IDataObject,
INodePropertyOptions,
JsonObject,
IHttpRequestMethods,
IHttpRequestOptions,
IRequestOptions,
IPollFunctions,
} from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import { resolveAuthUrl } from '../../credentials/SalesforceJwtApi.credentials';
type SalesforceApiError = {
errorCode?: string;
fields?: string[];
@@ -26,6 +22,8 @@ type SalesforceApiErrorResponse = {
error?: SalesforceApiError[];
};
const SALESFORCE_API_VERSION = 'v59.0';
function getOptions(
this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions,
method: IHttpRequestMethods,
@@ -42,7 +40,7 @@ function getOptions(
method,
body,
qs,
uri: `${instanceUrl}/services/data/v59.0${endpoint}`,
uri: `${instanceUrl}/services/data/${SALESFORCE_API_VERSION}${endpoint}`,
json: true,
};
@@ -53,45 +51,6 @@ function getOptions(
return options;
}
async function getAccessToken(
this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions,
credentials: ICredentialDataDecryptedObject,
): Promise<IDataObject> {
const now = moment().unix();
const authUrl = resolveAuthUrl(credentials);
const signature = jwt.sign(
{
iss: credentials.clientId as string,
sub: credentials.username as string,
aud: authUrl,
exp: now + 3 * 60,
},
credentials.privateKey as string,
{
algorithm: 'RS256',
header: {
alg: 'RS256',
},
},
);
const options: IRequestOptions = {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
method: 'POST',
form: {
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: signature,
},
uri: `${authUrl}/services/oauth2/token`,
json: true,
};
return await this.helpers.request(options);
}
export async function salesforceApiRequest(
this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions,
method: IHttpRequestMethods,
@@ -106,24 +65,30 @@ export async function salesforceApiRequest(
try {
if (authenticationMethod === 'jwt') {
// https://help.salesforce.com/articleView?id=remoteaccess_oauth_jwt_flow.htm&type=5
// The access token and instance URL are cached on the credential and reused
// across requests; the credential's authenticate hook attaches the Bearer
// header and resolves the relative URL against the cached instance URL.
const credentialsType = 'salesforceJwtApi';
const credentials = await this.getCredentials(credentialsType);
const response = await getAccessToken.call(this, credentials);
const { instance_url, access_token } = response;
const options = getOptions.call(
this,
const options: IHttpRequestOptions = {
headers: {
'Content-Type': 'application/json',
},
method,
uri || endpoint,
body,
qs,
instance_url as string,
);
this.logger.debug(
`Authentication for "Salesforce" node is using "jwt". Invoking URI ${options.uri}`,
);
options.headers!.Authorization = `Bearer ${access_token}`;
url: `/services/data/${SALESFORCE_API_VERSION}${uri || endpoint}`,
json: true,
};
if (!Object.keys(options.body as IDataObject).length) {
delete options.body;
}
Object.assign(options, option);
return await this.helpers.request(options);
this.logger.debug(
`Authentication for "Salesforce" node is using "jwt". Invoking URI ${options.url}`,
);
return await this.helpers.httpRequestWithAuthentication.call(this, credentialsType, options);
} else {
// https://help.salesforce.com/articleView?id=remoteaccess_oauth_web_server_flow.htm&type=5
const credentialsType = 'salesforceOAuth2Api';
@@ -146,9 +111,19 @@ export async function salesforceApiRequest(
return await this.helpers.requestOAuth2.call(this, credentialsType, options);
}
} catch (error) {
const salesforceError = error as SalesforceApiErrorResponse;
const salesforceError = error as SalesforceApiErrorResponse & {
cause?: { response?: { data?: unknown } };
};
const sfErrors = Array.isArray(salesforceError.error) ? salesforceError.error : [];
// Salesforce REST errors arrive as an array on `error.error` (OAuth2 path via the
// legacy request helper) or on the wrapped Axios error's response data under
// `error.cause` (JWT path via the authenticated request helper).
const responseData = salesforceError.cause?.response?.data;
const sfErrors: SalesforceApiError[] = Array.isArray(salesforceError.error)
? salesforceError.error
: Array.isArray(responseData)
? (responseData as SalesforceApiError[])
: [];
const allFields = sfErrors.flatMap((e) => e.fields ?? []).join(', ') || null;
@@ -655,15 +655,26 @@ describe('Salesforce -> GenericFunctions', () => {
beforeEach(() => {
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
mockRequest = vi.fn();
mockExecuteFunctions.helpers.request = mockRequest;
// The node now delegates JWT auth to the credential via the authenticated
// request helper, which caches and reuses the token across requests.
mockExecuteFunctions.helpers.httpRequestWithAuthentication = mockRequest;
vi.clearAllMocks();
// Setup default mocks
(mockJwt.sign as Mock).mockReturnValue('mock-jwt-signature');
mockExecuteFunctions.getNodeParameter.mockImplementation((param: string) => {
if (param === 'authentication') return 'jwt';
return undefined;
});
mockExecuteFunctions.logger = {
debug: vi.fn(),
} as any;
mockExecuteFunctions.getNode.mockReturnValue({
id: 'test-node',
name: 'Test Node',
type: 'n8n-nodes-base.salesforce',
typeVersion: 1,
position: [0, 0],
parameters: {},
});
});
afterEach(() => {
@@ -671,186 +682,30 @@ describe('Salesforce -> GenericFunctions', () => {
});
describe('JWT Authentication Flow', () => {
it('should authenticate using JWT with production environment', async () => {
const mockCredentials = {
clientId: 'test-client-id',
username: 'test@example.com',
privateKey: 'mock-private-key',
environment: 'production',
};
const mockResponse = {
access_token: 'mock-access-token',
instance_url: 'https://test.salesforce.com',
};
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
mockRequest.mockResolvedValue(mockResponse);
mockExecuteFunctions.logger = {
debug: vi.fn(),
} as any;
it('routes the request through the credential without signing or exchanging tokens', async () => {
mockRequest.mockResolvedValue({ records: [] });
await salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {});
// Verify JWT signature generation
expect(mockJwt.sign as Mock).toHaveBeenCalledWith(
{
iss: 'test-client-id',
sub: 'test@example.com',
aud: 'https://login.salesforce.com',
exp: 1640995200 + 3 * 60, // Current timestamp + 3 minutes
},
'mock-private-key',
{
algorithm: 'RS256',
header: {
alg: 'RS256',
},
},
);
// Verify token exchange request
expect(mockRequest).toHaveBeenCalledWith({
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
method: 'POST',
form: {
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: 'mock-jwt-signature',
},
uri: 'https://login.salesforce.com/services/oauth2/token',
json: true,
});
// Verify API request with bearer token
const expectedApiOptions = {
// The node delegates to the authenticated request helper with a RELATIVE url;
// the credential attaches the cached Bearer token and resolves the instance URL.
expect(mockRequest).toHaveBeenCalledWith('salesforceJwtApi', {
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer mock-access-token',
},
method: 'GET',
qs: {},
uri: 'https://test.salesforce.com/services/data/v59.0/test-endpoint',
json: true,
};
expect(mockRequest).toHaveBeenCalledWith(expectedApiOptions);
expect(mockExecuteFunctions.logger.debug).toHaveBeenCalledWith(
'Authentication for "Salesforce" node is using "jwt". Invoking URI https://test.salesforce.com/services/data/v59.0/test-endpoint',
);
});
it('should authenticate using JWT with sandbox environment', async () => {
const mockCredentials = {
clientId: 'sandbox-client-id',
username: 'sandbox@example.com',
privateKey: 'sandbox-private-key',
environment: 'sandbox',
};
const mockResponse = {
access_token: 'sandbox-access-token',
instance_url: 'https://test.my.salesforce.com',
};
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
mockRequest.mockResolvedValue(mockResponse);
mockExecuteFunctions.logger = {
debug: vi.fn(),
} as any;
await salesforceApiRequest.call(
mockExecuteFunctions,
'POST',
'/sandbox-endpoint',
{ data: 'test' },
{ param: 'value' },
);
// Verify JWT uses sandbox URL
expect(mockJwt.sign as Mock).toHaveBeenCalledWith(
{
iss: 'sandbox-client-id',
sub: 'sandbox@example.com',
aud: 'https://test.salesforce.com',
exp: 1640995200 + 3 * 60,
},
'sandbox-private-key',
{
algorithm: 'RS256',
header: {
alg: 'RS256',
},
},
);
// Verify token exchange request uses sandbox URL
expect(mockRequest).toHaveBeenCalledWith({
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
method: 'POST',
form: {
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: 'mock-jwt-signature',
},
uri: 'https://test.salesforce.com/services/oauth2/token',
url: '/services/data/v59.0/test-endpoint',
json: true,
});
// The token exchange now lives in the credential, not the node.
expect(mockJwt.sign as Mock).not.toHaveBeenCalled();
expect(mockExecuteFunctions.getCredentials).not.toHaveBeenCalled();
});
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: vi.fn(),
} as any;
await salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {});
expect(mockJwt.sign as 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',
username: 'test@example.com',
privateKey: 'mock-private-key',
environment: 'production',
};
const mockResponse = {
access_token: 'mock-access-token',
instance_url: 'https://test.salesforce.com',
};
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
mockRequest.mockResolvedValue(mockResponse);
mockExecuteFunctions.logger = {
debug: vi.fn(),
} as any;
it('forwards body and query parameters', async () => {
mockRequest.mockResolvedValue({});
const testBody = { name: 'Test Account', type: 'Customer' };
const testQs = { fields: 'Id,Name', limit: '10' };
@@ -863,39 +718,20 @@ describe('Salesforce -> GenericFunctions', () => {
testQs,
);
// Verify API request includes body and query parameters
const expectedApiOptions = {
expect(mockRequest).toHaveBeenCalledWith('salesforceJwtApi', {
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer mock-access-token',
},
method: 'POST',
body: testBody,
qs: testQs,
uri: 'https://test.salesforce.com/services/data/v59.0/test-endpoint',
url: '/services/data/v59.0/test-endpoint',
json: true,
};
expect(mockRequest).toHaveBeenCalledWith(expectedApiOptions);
});
});
it('should handle custom URI parameter', async () => {
const mockCredentials = {
clientId: 'test-client-id',
username: 'test@example.com',
privateKey: 'mock-private-key',
environment: 'production',
};
const mockResponse = {
access_token: 'mock-access-token',
instance_url: 'https://test.salesforce.com',
};
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
mockRequest.mockResolvedValue(mockResponse);
mockExecuteFunctions.logger = {
debug: vi.fn(),
} as any;
it('uses the custom URI when provided', async () => {
mockRequest.mockResolvedValue({});
await salesforceApiRequest.call(
mockExecuteFunctions,
@@ -906,42 +742,20 @@ describe('Salesforce -> GenericFunctions', () => {
'/custom-uri',
);
// Verify custom URI is used instead of endpoint
const expectedApiOptions = {
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer mock-access-token',
},
method: 'GET',
qs: {},
uri: 'https://test.salesforce.com/services/data/v59.0/custom-uri',
json: true,
};
expect(mockRequest).toHaveBeenCalledWith(expectedApiOptions);
expect(mockRequest).toHaveBeenCalledWith(
'salesforceJwtApi',
expect.objectContaining({
url: '/services/data/v59.0/custom-uri',
}),
);
});
it('should merge additional options', async () => {
const mockCredentials = {
clientId: 'test-client-id',
username: 'test@example.com',
privateKey: 'mock-private-key',
environment: 'production',
};
const mockResponse = {
access_token: 'mock-access-token',
instance_url: 'https://test.salesforce.com',
};
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
mockRequest.mockResolvedValue(mockResponse);
mockExecuteFunctions.logger = {
debug: vi.fn(),
} as any;
it('merges additional options', async () => {
mockRequest.mockResolvedValue({});
const additionalOptions = {
timeout: 30000,
resolveWithFullResponse: true,
returnFullResponse: true,
};
await salesforceApiRequest.call(
@@ -954,238 +768,74 @@ describe('Salesforce -> GenericFunctions', () => {
additionalOptions,
);
// Verify additional options are merged
const expectedApiOptions = {
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer mock-access-token',
},
method: 'GET',
qs: {},
uri: 'https://test.salesforce.com/services/data/v59.0/test-endpoint',
json: true,
timeout: 30000,
resolveWithFullResponse: true,
};
expect(mockRequest).toHaveBeenCalledWith(
'salesforceJwtApi',
expect.objectContaining({
url: '/services/data/v59.0/test-endpoint',
timeout: 30000,
returnFullResponse: true,
}),
);
});
expect(mockRequest).toHaveBeenCalledWith(expectedApiOptions);
it('omits an empty body', async () => {
mockRequest.mockResolvedValue({});
await salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {});
expect(mockRequest.mock.calls[0][1]).not.toHaveProperty('body');
});
it('logs the relative URL being invoked', async () => {
mockRequest.mockResolvedValue({});
await salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {});
expect(mockExecuteFunctions.logger.debug).toHaveBeenCalledWith(
'Authentication for "Salesforce" node is using "jwt". Invoking URI /services/data/v59.0/test-endpoint',
);
});
});
describe('JWT Authentication Error Handling', () => {
it('should handle credential retrieval errors', async () => {
const credentialError = new Error('Failed to get credentials');
mockExecuteFunctions.getCredentials.mockRejectedValue(credentialError);
mockExecuteFunctions.getNode.mockReturnValue({
id: 'test-node',
name: 'Test Node',
type: 'n8n-nodes-base.salesforce',
typeVersion: 1,
position: [0, 0],
parameters: {},
});
await expect(
salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {}),
).rejects.toThrow(NodeApiError);
expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledWith('salesforceJwtApi');
});
it('should handle JWT token exchange errors', async () => {
const mockCredentials = {
clientId: 'test-client-id',
username: 'test@example.com',
privateKey: 'mock-private-key',
environment: 'production',
};
const tokenError = new Error('Invalid JWT signature');
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
mockRequest.mockRejectedValue(tokenError);
mockExecuteFunctions.getNode.mockReturnValue({
id: 'test-node',
name: 'Test Node',
type: 'n8n-nodes-base.salesforce',
typeVersion: 1,
position: [0, 0],
parameters: {},
});
it('wraps request errors in NodeApiError', async () => {
mockRequest.mockRejectedValue(new Error('API rate limit exceeded'));
await expect(
salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {}),
).rejects.toThrow(NodeApiError);
});
it('should handle API request errors after successful authentication', async () => {
const mockCredentials = {
clientId: 'test-client-id',
username: 'test@example.com',
privateKey: 'mock-private-key',
environment: 'production',
};
const mockResponse = {
access_token: 'mock-access-token',
instance_url: 'https://test.salesforce.com',
};
const apiError = new Error('API rate limit exceeded');
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
mockRequest
.mockResolvedValueOnce(mockResponse) // First call succeeds (token exchange)
.mockRejectedValueOnce(apiError); // Second call fails (API request)
mockExecuteFunctions.getNode.mockReturnValue({
id: 'test-node',
name: 'Test Node',
type: 'n8n-nodes-base.salesforce',
typeVersion: 1,
position: [0, 0],
parameters: {},
});
mockExecuteFunctions.logger = {
debug: vi.fn(),
} as any;
await expect(
salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {}),
).rejects.toThrow(NodeApiError);
// Verify that token exchange succeeded but API request failed
expect(mockRequest).toHaveBeenCalledTimes(2);
});
it('should handle missing instance_url in token response', async () => {
const mockCredentials = {
clientId: 'test-client-id',
username: 'test@example.com',
privateKey: 'mock-private-key',
environment: 'production',
};
const mockResponse = {
access_token: 'mock-access-token',
// Missing instance_url
};
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
mockRequest.mockResolvedValue(mockResponse);
mockExecuteFunctions.getNode.mockReturnValue({
id: 'test-node',
name: 'Test Node',
type: 'n8n-nodes-base.salesforce',
typeVersion: 1,
position: [0, 0],
parameters: {},
});
mockExecuteFunctions.logger = {
debug: vi.fn(),
} as any;
// Should not throw error but handle gracefully
await salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {});
// Verify API call was made with undefined instance_url
expect(mockRequest).toHaveBeenCalledTimes(2);
});
});
describe('JWT Signature Generation', () => {
it('should generate correct JWT payload for production environment', async () => {
const mockCredentials = {
clientId: 'prod-client-id',
username: 'prod@example.com',
privateKey: 'prod-private-key',
environment: 'production',
};
const mockResponse = {
access_token: 'prod-access-token',
instance_url: 'https://prod.salesforce.com',
};
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
mockRequest.mockResolvedValue(mockResponse);
mockExecuteFunctions.logger = {
debug: vi.fn(),
} as any;
await salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {});
expect(mockJwt.sign as Mock).toHaveBeenCalledWith(
{
iss: 'prod-client-id',
sub: 'prod@example.com',
aud: 'https://login.salesforce.com',
exp: 1640995200 + 180, // 3 minutes = 180 seconds
},
'prod-private-key',
{
algorithm: 'RS256',
header: {
alg: 'RS256',
it('preserves the Salesforce error code and fields from the wrapped error', async () => {
// The authenticated helper wraps transport errors and keeps the original
// Axios error (with the Salesforce error body) under `cause`.
mockRequest.mockRejectedValue({
cause: {
response: {
data: [
{
fields: ['AnnualRevenue'],
message: 'Annual Revenue cannot be negative.',
errorCode: 'FIELD_CUSTOM_VALIDATION_EXCEPTION',
},
{
fields: ['Phone'],
message: 'Phone number is invalid.',
errorCode: 'FIELD_CUSTOM_VALIDATION_EXCEPTION',
},
],
},
},
);
});
});
it('should generate correct JWT payload for sandbox environment', async () => {
const mockCredentials = {
clientId: 'sandbox-client-id',
username: 'sandbox@example.com',
privateKey: 'sandbox-private-key',
environment: 'sandbox',
};
const mockResponse = {
access_token: 'sandbox-access-token',
instance_url: 'https://sandbox.salesforce.com',
};
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
mockRequest.mockResolvedValue(mockResponse);
mockExecuteFunctions.logger = {
debug: vi.fn(),
} as any;
await salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {});
expect(mockJwt.sign as Mock).toHaveBeenCalledWith(
{
iss: 'sandbox-client-id',
sub: 'sandbox@example.com',
aud: 'https://test.salesforce.com', // Sandbox uses test.salesforce.com
exp: 1640995200 + 180,
await expect(
salesforceApiRequest.call(mockExecuteFunctions, 'POST', '/sobjects/Lead', {}),
).rejects.toMatchObject({
context: {
errorCode: 'FIELD_CUSTOM_VALIDATION_EXCEPTION',
fields: 'AnnualRevenue, Phone',
},
'sandbox-private-key',
{
algorithm: 'RS256',
header: {
alg: 'RS256',
},
},
);
});
it('should use RS256 algorithm and proper header configuration', async () => {
const mockCredentials = {
clientId: 'test-client-id',
username: 'test@example.com',
privateKey: 'test-private-key',
environment: 'production',
};
const mockResponse = {
access_token: 'test-access-token',
instance_url: 'https://test.salesforce.com',
};
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
mockRequest.mockResolvedValue(mockResponse);
mockExecuteFunctions.logger = {
debug: vi.fn(),
} as any;
await salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {});
const jwtOptions = (mockJwt.sign as Mock).mock.calls[0][2];
expect(jwtOptions.algorithm).toBe('RS256');
expect(jwtOptions.header?.alg).toBe('RS256');
});
});
});
@@ -172,4 +172,35 @@ describe('Salesforce Node', () => {
workflowFiles: ['opportunities.workflow.json'],
});
});
describe('jwt authentication', () => {
// The cached access token and instance URL are stored on the credential; the node
// sends a relative URL and the credential resolves it against the instance URL and
// attaches the cached Bearer token instead of logging in per request.
const jwtCredentials = {
salesforceJwtApi: {
accessToken: 'CACHEDTOKEN',
instanceUrl: 'https://salesforce.instance',
clientId: 'connected-app-client-id',
username: 'user@example.com',
privateKey: '-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----',
environment: 'production',
},
};
beforeAll(() => {
nock('https://salesforce.instance/services/data/v59.0')
.matchHeader('authorization', 'Bearer CACHEDTOKEN')
.get('/query')
.query({
q: 'SELECT id, name, type FROM Account',
})
.reply(200, { records: accounts });
});
new NodeTestHarness().setupTests({
credentials: jwtCredentials,
workflowFiles: ['search-jwt.workflow.json'],
});
});
});
@@ -0,0 +1,99 @@
{
"name": "search-jwt.workflow",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [-240, 100],
"id": "e3a94904-ddfa-4fe8-bac2-e2c796c677a3",
"name": "When clicking Execute workflow"
},
{
"parameters": {
"authentication": "jwt",
"resource": "search",
"query": "SELECT id, name, type FROM Account"
},
"type": "n8n-nodes-base.salesforce",
"typeVersion": 1,
"position": [-20, 100],
"id": "0fcf65ac-c9a3-48b5-9c39-599216208b62",
"name": "Salesforce",
"credentials": {
"salesforceJwtApi": {
"id": "jWtFg0tv79sQS6pc",
"name": "Salesforce JWT account"
}
}
},
{
"parameters": {},
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [200, 100],
"id": "8319a07f-465f-4456-b6b0-012aa36ce08b",
"name": "No Operation, do nothing"
}
],
"pinData": {
"No Operation, do nothing": [
{
"json": {
"attributes": {
"type": "Account",
"url": "/services/data/v59.0/sobjects/Account/id1"
},
"Id": "id1",
"Name": "Account 1",
"Type": "Customer - Direct"
}
},
{
"json": {
"attributes": {
"type": "Account",
"url": "/services/data/v59.0/sobjects/Account/id2"
},
"Id": "id2",
"Name": "Account 2",
"Type": "Customer - Direct"
}
}
]
},
"connections": {
"When clicking Execute workflow": {
"main": [
[
{
"node": "Salesforce",
"type": "main",
"index": 0
}
]
]
},
"Salesforce": {
"main": [
[
{
"node": "No Operation, do nothing",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "ca0a5b64-d016-4412-8ea9-0cefc269f2c2",
"meta": {
"instanceId": "e115be144a6a5547dbfca93e774dfffa178aa94a181854c13e2ce5e14d195b2e"
},
"id": "ODC4s7m6nIRLkeF0",
"tags": []
}