From 17d93954772be07a599c5d46dfac1359eaf5fd51 Mon Sep 17 00:00:00 2001 From: Nick Aguilar Date: Tue, 21 May 2024 10:05:10 -0700 Subject: [PATCH] [Salesforce] Alternative authentication flow (#2023) * Implementation of a flow where the conventional OAuth refreshToken flow is attempted and the username+password flow is tried if that fails * Throw an error if no authentication method returns rather than returning undefined, fixes build * WIP - generate new request client that is extended with password generated access tokens. No Build * Exports createRequestClient from core and uses it in salesforce * Correctly use login.salesforce url rather than individual instance url when authenticating * Reverts refreshAccessToken to original version. Uses process.env. client ID and secret to pull from chamber * Uses original access_token reference for result of refresh request * References salesforce_client_id/secret rather than the actions_ prefixed verion, which doesn't exist * Adds username+password auth to all actions, including dynamic fields * Removes unused auth methods in the Salesforce class * Uses username+password for refreshAccessToken flow where appropriate, uses security_token field to construct password sent to salesforce, updates descriptions * Unit test for username+password flow * Tests functionality when no security_token is provided as well * Adds a comment --- packages/core/src/index.ts | 1 + .../__tests__/sf-operations.test.ts | 69 +++++++++++++++- .../destinations/salesforce/account/index.ts | 6 +- .../destinations/salesforce/cases/index.ts | 6 +- .../destinations/salesforce/contact/index.ts | 6 +- .../salesforce/customObject/index.ts | 11 ++- .../salesforce/generated-types.ts | 12 +++ .../src/destinations/salesforce/index.ts | 33 +++++++- .../src/destinations/salesforce/lead/index.ts | 6 +- .../salesforce/opportunity/index.ts | 6 +- .../destinations/salesforce/sf-operations.ts | 80 ++++++++++++++++++- 11 files changed, 213 insertions(+), 23 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2e23dfe8be..e89318c0bf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -29,6 +29,7 @@ export { export { createTestEvent } from './create-test-event' export { createTestIntegration } from './create-test-integration' export { default as createInstance } from './request-client' +export { default as createRequestClient } from './create-request-client' export { defaultValues } from './defaults' export { IntegrationError, diff --git a/packages/destination-actions/src/destinations/salesforce/__tests__/sf-operations.test.ts b/packages/destination-actions/src/destinations/salesforce/__tests__/sf-operations.test.ts index 78db74cd73..cac8a675a4 100644 --- a/packages/destination-actions/src/destinations/salesforce/__tests__/sf-operations.test.ts +++ b/packages/destination-actions/src/destinations/salesforce/__tests__/sf-operations.test.ts @@ -1,8 +1,9 @@ import nock from 'nock' import createRequestClient from '../../../../../core/src/create-request-client' -import Salesforce from '../sf-operations' +import Salesforce, { authenticateWithPassword } from '../sf-operations' import { API_VERSION } from '../sf-operations' import type { GenericPayload } from '../sf-types' +import { Settings } from '../generated-types' const settings = { instanceUrl: 'https://test.salesforce.com/' @@ -771,4 +772,70 @@ describe('Salesforce', () => { ) }) }) + + describe('Username & Password flow', () => { + const usernamePasswordOnly: Settings = { + username: 'spongebob@seamail.com', + auth_password: 'gary1997', + instanceUrl: 'https://spongebob.salesforce.com/', + isSandbox: false + } + + const usernamePasswordAndToken: Settings = { + username: 'spongebob@seamail.com', + auth_password: 'gary1997', + instanceUrl: 'https://spongebob.salesforce.com/', + isSandbox: false, + security_token: 'abc123' + } + + process.env['SALESFORCE_CLIENT_ID'] = 'id' + process.env['SALESFORCE_CLIENT_SECRET'] = 'secret' + + it('should authenticate using the username and password flow when only the username and password are provided', async () => { + nock('https://login.salesforce.com/services/oauth2/token') + .post('', { + grant_type: 'password', + client_id: 'id', + client_secret: 'secret', + username: usernamePasswordOnly.username, + password: usernamePasswordOnly.auth_password + }) + .reply(201, { + access_token: 'abc' + }) + + const res = await authenticateWithPassword( + usernamePasswordOnly.username as string, // tells typescript that these are defined + usernamePasswordOnly.auth_password as string, + usernamePasswordOnly.security_token, + usernamePasswordOnly.isSandbox + ) + + expect(res.accessToken).toEqual('abc') + }) + + it('should authenticate using the username and password flow when the username, password and security token are provided', async () => { + nock('https://login.salesforce.com/services/oauth2/token') + .post('', { + grant_type: 'password', + client_id: 'id', + client_secret: 'secret', + username: usernamePasswordAndToken.username, + password: `${usernamePasswordAndToken.auth_password}${usernamePasswordAndToken.security_token}` + }) + .reply(201, { + access_token: 'abc' + }) + + const res = await authenticateWithPassword( + usernamePasswordAndToken.username as string, // tells typescript that these are defined + usernamePasswordAndToken.auth_password as string, + usernamePasswordAndToken.security_token, + usernamePasswordAndToken.isSandbox + ) + + expect(res.accessToken).toEqual('abc') + }) + }) }) diff --git a/packages/destination-actions/src/destinations/salesforce/account/index.ts b/packages/destination-actions/src/destinations/salesforce/account/index.ts index aded8eee45..bbb84e11b9 100644 --- a/packages/destination-actions/src/destinations/salesforce/account/index.ts +++ b/packages/destination-actions/src/destinations/salesforce/account/index.ts @@ -1,6 +1,6 @@ import { ActionDefinition, IntegrationError } from '@segment/actions-core' import type { Settings } from '../generated-types' -import Salesforce from '../sf-operations' +import Salesforce, { generateSalesforceRequest } from '../sf-operations' import { bulkUpsertExternalId, bulkUpdateRecordId, @@ -181,7 +181,7 @@ const action: ActionDefinition = { customFields: customFields }, perform: async (request, { settings, payload }) => { - const sf: Salesforce = new Salesforce(settings.instanceUrl, request) + const sf: Salesforce = new Salesforce(settings.instanceUrl, await generateSalesforceRequest(settings, request)) if (payload.operation === 'create') { if (!payload.name) { @@ -208,7 +208,7 @@ const action: ActionDefinition = { } }, performBatch: async (request, { settings, payload }) => { - const sf: Salesforce = new Salesforce(settings.instanceUrl, request) + const sf: Salesforce = new Salesforce(settings.instanceUrl, await generateSalesforceRequest(settings, request)) if (payload[0].operation === 'upsert') { if (!payload[0].name) { diff --git a/packages/destination-actions/src/destinations/salesforce/cases/index.ts b/packages/destination-actions/src/destinations/salesforce/cases/index.ts index d7446f5b55..599688a95d 100644 --- a/packages/destination-actions/src/destinations/salesforce/cases/index.ts +++ b/packages/destination-actions/src/destinations/salesforce/cases/index.ts @@ -12,7 +12,7 @@ import { recordMatcherOperator, batch_size } from '../sf-properties' -import Salesforce from '../sf-operations' +import Salesforce, { generateSalesforceRequest } from '../sf-operations' const OBJECT_NAME = 'Case' @@ -35,7 +35,7 @@ const action: ActionDefinition = { customFields: customFields }, perform: async (request, { settings, payload }) => { - const sf: Salesforce = new Salesforce(settings.instanceUrl, request) + const sf: Salesforce = new Salesforce(settings.instanceUrl, await generateSalesforceRequest(settings, request)) if (payload.operation === 'create') { return await sf.createRecord(payload, OBJECT_NAME) @@ -56,7 +56,7 @@ const action: ActionDefinition = { } }, performBatch: async (request, { settings, payload }) => { - const sf: Salesforce = new Salesforce(settings.instanceUrl, request) + const sf: Salesforce = new Salesforce(settings.instanceUrl, await generateSalesforceRequest(settings, request)) return sf.bulkHandler(payload, OBJECT_NAME) } diff --git a/packages/destination-actions/src/destinations/salesforce/contact/index.ts b/packages/destination-actions/src/destinations/salesforce/contact/index.ts index 5b45e1bfde..95df669ca0 100644 --- a/packages/destination-actions/src/destinations/salesforce/contact/index.ts +++ b/packages/destination-actions/src/destinations/salesforce/contact/index.ts @@ -12,7 +12,7 @@ import { recordMatcherOperator, batch_size } from '../sf-properties' -import Salesforce from '../sf-operations' +import Salesforce, { generateSalesforceRequest } from '../sf-operations' const OBJECT_NAME = 'Contact' @@ -132,7 +132,7 @@ const action: ActionDefinition = { customFields: customFields }, perform: async (request, { settings, payload }) => { - const sf: Salesforce = new Salesforce(settings.instanceUrl, request) + const sf: Salesforce = new Salesforce(settings.instanceUrl, await generateSalesforceRequest(settings, request)) if (payload.operation === 'create') { if (!payload.last_name) { @@ -159,7 +159,7 @@ const action: ActionDefinition = { } }, performBatch: async (request, { settings, payload }) => { - const sf: Salesforce = new Salesforce(settings.instanceUrl, request) + const sf: Salesforce = new Salesforce(settings.instanceUrl, await generateSalesforceRequest(settings, request)) if (payload[0].operation === 'upsert') { if (!payload[0].last_name) { diff --git a/packages/destination-actions/src/destinations/salesforce/customObject/index.ts b/packages/destination-actions/src/destinations/salesforce/customObject/index.ts index 2951debf66..470ab23f10 100644 --- a/packages/destination-actions/src/destinations/salesforce/customObject/index.ts +++ b/packages/destination-actions/src/destinations/salesforce/customObject/index.ts @@ -12,7 +12,7 @@ import { recordMatcherOperator, batch_size } from '../sf-properties' -import Salesforce from '../sf-operations' +import Salesforce, { generateSalesforceRequest } from '../sf-operations' import { PayloadValidationError } from '@segment/actions-core' const OPERATIONS_WITH_CUSTOM_FIELDS = ['create', 'update', 'upsert'] @@ -39,7 +39,10 @@ const action: ActionDefinition = { }, dynamicFields: { customObjectName: async (request, data) => { - const sf: Salesforce = new Salesforce(data.settings.instanceUrl, request) + const sf: Salesforce = new Salesforce( + data.settings.instanceUrl, + await generateSalesforceRequest(data.settings, request) + ) return sf.customObjectName() } @@ -49,7 +52,7 @@ const action: ActionDefinition = { throw new PayloadValidationError('Custom fields are required for this operation.') } - const sf: Salesforce = new Salesforce(settings.instanceUrl, request) + const sf: Salesforce = new Salesforce(settings.instanceUrl, await generateSalesforceRequest(settings, request)) if (payload.operation === 'create') { return await sf.createRecord(payload, payload.customObjectName) @@ -74,7 +77,7 @@ const action: ActionDefinition = { throw new PayloadValidationError('Custom fields are required for this operation.') } - const sf: Salesforce = new Salesforce(settings.instanceUrl, request) + const sf: Salesforce = new Salesforce(settings.instanceUrl, await generateSalesforceRequest(settings, request)) return sf.bulkHandler(payload, payload[0].customObjectName) } diff --git a/packages/destination-actions/src/destinations/salesforce/generated-types.ts b/packages/destination-actions/src/destinations/salesforce/generated-types.ts index 20a5687fb6..b449612538 100644 --- a/packages/destination-actions/src/destinations/salesforce/generated-types.ts +++ b/packages/destination-actions/src/destinations/salesforce/generated-types.ts @@ -9,4 +9,16 @@ export interface Settings { * Enable to authenticate into a sandbox instance. You can log in to a sandbox by appending the sandbox name to your Salesforce username. For example, if a username for a production org is user@acme.com and the sandbox is named test, the username to log in to the sandbox is user@acme.com.test. If you are already authenticated, please disconnect and reconnect with your sandbox username. */ isSandbox?: boolean + /** + * The username of the Salesforce account you want to connect to. When all three of username, password, and security token are provided, a username-password flow is used to authenticate. This field is hidden to all users except those who have opted in to the username+password flow. + */ + username?: string + /** + * The password of the Salesforce account you want to connect to. When all three of username, password, and security token are provided, a username-password flow is used to authenticate. This field is hidden to all users except those who have opted in to the username+password flow. + */ + auth_password?: string + /** + * The security token of the Salesforce account you want to connect to. When all three of username, password, and security token are provided, a username-password flow is used to authenticate. This value will be appended to the password field to construct the credential used for authentication. This field is hidden to all users except those who have opted in to the username+password flow. + */ + security_token?: string } diff --git a/packages/destination-actions/src/destinations/salesforce/index.ts b/packages/destination-actions/src/destinations/salesforce/index.ts index 9e330728a6..80018cddcc 100644 --- a/packages/destination-actions/src/destinations/salesforce/index.ts +++ b/packages/destination-actions/src/destinations/salesforce/index.ts @@ -1,4 +1,4 @@ -import type { DestinationDefinition } from '@segment/actions-core' +import { DestinationDefinition } from '@segment/actions-core' import type { Settings } from './generated-types' // This has to be 'cases' because 'case' is a Javascript reserved word import cases from './cases' @@ -7,6 +7,7 @@ import opportunity from './opportunity' import customObject from './customObject' import contact from './contact' import account from './account' +import { authenticateWithPassword } from './sf-operations' interface RefreshTokenResponse { access_token: string @@ -33,9 +34,39 @@ const destination: DestinationDefinition = { 'Enable to authenticate into a sandbox instance. You can log in to a sandbox by appending the sandbox name to your Salesforce username. For example, if a username for a production org is user@acme.com and the sandbox is named test, the username to log in to the sandbox is user@acme.com.test. If you are already authenticated, please disconnect and reconnect with your sandbox username.', type: 'boolean', default: false + }, + username: { + label: 'Username', + description: + 'The username of the Salesforce account you want to connect to. When all three of username, password, and security token are provided, a username-password flow is used to authenticate. This field is hidden to all users except those who have opted in to the username+password flow.', + type: 'string' + }, + auth_password: { + // auth_ prefix is used because password is a reserved word + label: 'Password', + description: + 'The password of the Salesforce account you want to connect to. When all three of username, password, and security token are provided, a username-password flow is used to authenticate. This field is hidden to all users except those who have opted in to the username+password flow.', + type: 'string' + }, + security_token: { + label: 'Security Token', + description: + 'The security token of the Salesforce account you want to connect to. When all three of username, password, and security token are provided, a username-password flow is used to authenticate. This value will be appended to the password field to construct the credential used for authentication. This field is hidden to all users except those who have opted in to the username+password flow.', + type: 'string' } }, refreshAccessToken: async (request, { auth, settings }) => { + if (settings.username && settings.auth_password) { + const { accessToken } = await authenticateWithPassword( + settings.username, + settings.auth_password, + settings.security_token, + settings.isSandbox + ) + + return { accessToken } + } + // Return a request that refreshes the access_token if the API supports it const baseUrl = settings.isSandbox ? 'https://test.salesforce.com' : 'https://login.salesforce.com' const res = await request(`${baseUrl}/services/oauth2/token`, { diff --git a/packages/destination-actions/src/destinations/salesforce/lead/index.ts b/packages/destination-actions/src/destinations/salesforce/lead/index.ts index 7959c6cd5f..be5aff7008 100644 --- a/packages/destination-actions/src/destinations/salesforce/lead/index.ts +++ b/packages/destination-actions/src/destinations/salesforce/lead/index.ts @@ -12,7 +12,7 @@ import { recordMatcherOperator, batch_size } from '../sf-properties' -import Salesforce from '../sf-operations' +import Salesforce, { generateSalesforceRequest } from '../sf-operations' const OBJECT_NAME = 'Lead' @@ -139,7 +139,7 @@ const action: ActionDefinition = { customFields: customFields }, perform: async (request, { settings, payload }) => { - const sf: Salesforce = new Salesforce(settings.instanceUrl, request) + const sf: Salesforce = new Salesforce(settings.instanceUrl, await generateSalesforceRequest(settings, request)) if (payload.operation === 'create') { if (!payload.last_name) { @@ -166,7 +166,7 @@ const action: ActionDefinition = { } }, performBatch: async (request, { settings, payload }) => { - const sf: Salesforce = new Salesforce(settings.instanceUrl, request) + const sf: Salesforce = new Salesforce(settings.instanceUrl, await generateSalesforceRequest(settings, request)) if (payload[0].operation === 'upsert') { if (!payload[0].last_name) { diff --git a/packages/destination-actions/src/destinations/salesforce/opportunity/index.ts b/packages/destination-actions/src/destinations/salesforce/opportunity/index.ts index 217f6c7c5e..9ec852e839 100644 --- a/packages/destination-actions/src/destinations/salesforce/opportunity/index.ts +++ b/packages/destination-actions/src/destinations/salesforce/opportunity/index.ts @@ -1,6 +1,6 @@ import { ActionDefinition, IntegrationError } from '@segment/actions-core' import type { Settings } from '../generated-types' -import Salesforce from '../sf-operations' +import Salesforce, { generateSalesforceRequest } from '../sf-operations' import { bulkUpsertExternalId, bulkUpdateRecordId, @@ -56,7 +56,7 @@ const action: ActionDefinition = { customFields: customFields }, perform: async (request, { settings, payload }) => { - const sf: Salesforce = new Salesforce(settings.instanceUrl, request) + const sf: Salesforce = new Salesforce(settings.instanceUrl, await generateSalesforceRequest(settings, request)) if (payload.operation === 'create') { if (!payload.close_date || !payload.name || !payload.stage_name) { @@ -83,7 +83,7 @@ const action: ActionDefinition = { } }, performBatch: async (request, { settings, payload }) => { - const sf: Salesforce = new Salesforce(settings.instanceUrl, request) + const sf: Salesforce = new Salesforce(settings.instanceUrl, await generateSalesforceRequest(settings, request)) if (payload[0].operation === 'upsert') { if (!payload[0].close_date || !payload[0].name || !payload[0].stage_name) { diff --git a/packages/destination-actions/src/destinations/salesforce/sf-operations.ts b/packages/destination-actions/src/destinations/salesforce/sf-operations.ts index cf538f5763..5076107d64 100644 --- a/packages/destination-actions/src/destinations/salesforce/sf-operations.ts +++ b/packages/destination-actions/src/destinations/salesforce/sf-operations.ts @@ -1,8 +1,9 @@ -import { IntegrationError, ModifiedResponse, RequestClient } from '@segment/actions-core' +import { IntegrationError, ModifiedResponse, RequestClient, RefreshAccessTokenResult } from '@segment/actions-core' import type { GenericPayload } from './sf-types' import { mapObjectToShape } from './sf-object-to-shape' import { buildCSVData, validateInstanceURL } from './sf-utils' -import { DynamicFieldResponse } from '@segment/actions-core' +import { DynamicFieldResponse, createRequestClient } from '@segment/actions-core' +import { Settings } from './generated-types' export const API_VERSION = 'v53.0' @@ -27,6 +28,77 @@ const validateSOQLOperator = (operator: string | undefined): SOQLOperator => { return operator } +export const generateSalesforceRequest = async (settings: Settings, request: RequestClient) => { + if (!settings.auth_password || !settings.username) { + return request + } + + const { accessToken } = await authenticateWithPassword( + settings.username, + settings.auth_password, + settings.security_token, + settings.isSandbox + ) + + const passwordRequestClient = createRequestClient({ + headers: { + Authorization: `Bearer ${accessToken}` + } + }) + + return passwordRequestClient +} + +/** + * Salesforce requires that the password provided for authentication be a concatenation of the + * user password + the user security token. + * For more info see: https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_username_password_flow.htm&type=5 + */ +const constructPassword = (password: string, securityToken?: string): string => { + let combined = '' + if (password) { + combined = password + } + + if (securityToken) { + combined = password + securityToken + } + + return combined +} + +export const authenticateWithPassword = async ( + username: string, + auth_password: string, + security_token?: string, + isSandbox?: boolean +): Promise => { + const clientId = process.env.SALESFORCE_CLIENT_ID + const clientSecret = process.env.SALESFORCE_CLIENT_SECRET + + if (!clientId || !clientSecret) { + throw new IntegrationError('Missing Salesforce client ID or client secret', 'Missing Credentials', 400) + } + + const newRequest = createRequestClient() + + const loginUrl = isSandbox ? 'https://test.salesforce.com' : 'https://login.salesforce.com' + const password = constructPassword(auth_password, security_token) + + const res = await newRequest(`${loginUrl}/services/oauth2/token`, { + method: 'post', + body: new URLSearchParams({ + grant_type: 'password', + client_id: clientId, + client_secret: clientSecret, + username: username, + password + }) + }) + + return { accessToken: res.data.access_token } +} + interface Records { Id?: string } @@ -63,6 +135,10 @@ interface SalesforceError { } } +interface SalesforceRefreshTokenResponse { + access_token: string +} + type SOQLOperator = 'OR' | 'AND' export default class Salesforce {