diff --git a/packages/cli/src/__tests__/credentials-helper.test.ts b/packages/cli/src/__tests__/credentials-helper.test.ts index 8cef151277d..6aa42f34fab 100644 --- a/packages/cli/src/__tests__/credentials-helper.test.ts +++ b/packages/cli/src/__tests__/credentials-helper.test.ts @@ -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' }); + }); + }); }); diff --git a/packages/nodes-base/credentials/SalesforceJwtApi.credentials.ts b/packages/nodes-base/credentials/SalesforceJwtApi.credentials.ts index d97ef162752..22f7b52e3a1 100644 --- a/packages/nodes-base/credentials/SalesforceJwtApi.credentials.ts +++ b/packages/nodes-base/credentials/SalesforceJwtApi.credentials.ts @@ -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 { + // 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 { + 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 = { diff --git a/packages/nodes-base/credentials/test/SalesforceJwtApi.credentials.test.ts b/packages/nodes-base/credentials/test/SalesforceJwtApi.credentials.test.ts index a413fb70218..6fc4e772b4d 100644 --- a/packages/nodes-base/credentials/test/SalesforceJwtApi.credentials.test.ts +++ b/packages/nodes-base/credentials/test/SalesforceJwtApi.credentials.test.ts @@ -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(); }); }); }); diff --git a/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts b/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts index 353cff173e7..df6d91c5d5f 100644 --- a/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts @@ -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 { - 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; diff --git a/packages/nodes-base/nodes/Salesforce/__test__/GenericFunctions.test.ts b/packages/nodes-base/nodes/Salesforce/__test__/GenericFunctions.test.ts index 5fa4ce7a7a1..4986e90b4f2 100644 --- a/packages/nodes-base/nodes/Salesforce/__test__/GenericFunctions.test.ts +++ b/packages/nodes-base/nodes/Salesforce/__test__/GenericFunctions.test.ts @@ -655,15 +655,26 @@ describe('Salesforce -> GenericFunctions', () => { beforeEach(() => { mockExecuteFunctions = mockDeep(); 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'); + }); }); }); diff --git a/packages/nodes-base/nodes/Salesforce/__test__/node/Salesforce.node.test.ts b/packages/nodes-base/nodes/Salesforce/__test__/node/Salesforce.node.test.ts index f87b1eb4a4d..dacceb6e05a 100644 --- a/packages/nodes-base/nodes/Salesforce/__test__/node/Salesforce.node.test.ts +++ b/packages/nodes-base/nodes/Salesforce/__test__/node/Salesforce.node.test.ts @@ -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'], + }); + }); }); diff --git a/packages/nodes-base/nodes/Salesforce/__test__/node/search-jwt.workflow.json b/packages/nodes-base/nodes/Salesforce/__test__/node/search-jwt.workflow.json new file mode 100644 index 00000000000..5aaed0b94bf --- /dev/null +++ b/packages/nodes-base/nodes/Salesforce/__test__/node/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": [] +}