diff --git a/docs-v2/integrations/all/salesforce-cdp.mdx b/docs-v2/integrations/all/salesforce-cdp.mdx
new file mode 100644
index 00000000000..6dc8de6c4f9
--- /dev/null
+++ b/docs-v2/integrations/all/salesforce-cdp.mdx
@@ -0,0 +1,58 @@
+---
+title: Salesforce (Data Cloud)
+sidebarTitle: Salesforce (Data Cloud)
+provider: salesforce-cdp
+---
+
+import Overview from "/snippets/overview.mdx";
+import PreBuiltTooling from "/snippets/generated/salesforce-cdp/PreBuiltTooling.mdx";
+
+import PreBuiltUseCases from "/snippets/generated/salesforce-cdp/PreBuiltUseCases.mdx"
+
+
+
+
+
+## Access requirements
+| Pre-Requisites | Status | Comment|
+| - | - | - |
+| Paid dev account | ❓ | |
+| Paid test account | ✅ | Requires an active Salesforce Data Cloud subscription. |
+| Partnership | ❓ | |
+| App review | ❓ | |
+| Security audit | ❓ | |
+
+
+## Setup guide
+
+_No setup guide yet._
+
+Need help getting started? Get help in the [community](https://nango.dev/slack).
+
+Contribute improvements to the setup guide by [editing this page](https://github.com/nangohq/nango/tree/master/docs-v2/integrations/all/salesforce.mdx)
+
+
+## Useful links
+- [Salesforce Data Cloud OAuth documentation](https://developer.salesforce.com/docs/marketing/marketing-cloud-growth/guide/mc-connect-apis-data-cloud.html)
+- [Web API docs (their REST API)](https://developer.salesforce.com/docs/marketing/marketing-cloud-growth/references/mc-summary/mc-summary.html)
+
+Contribute useful links by [editing this page](https://github.com/nangohq/nango/tree/master/docs-v2/integrations/all/salesforce-cdp.mdx)
+
+## Connection Configuration in Nango
+
+- Salesforce (Data Cloud) uses a different API base URL, called the `instance_url`, for each customer.
+
+- Nango automatically retrieves the `instance_url` (e.g. `https://yourInstance.salesforce.com/`) from Salesforce and stores it in the [Connection config](/guides/api-authorization/authorize-in-your-app-default-ui#apis-requiring-connection-specific-configuration-for-authorization) for you.
+
+- If you use the Nango Proxy, it is automatically using the correct API base URL. But, if needed, you can retrieve the `instance_url` with the [backend SDK](/reference/sdks/node#get-a-connection-with-credentials) or [Connections API](/reference/api/connection/get).
+
+## API gotchas
+
+Contribute API gotchas by [editing this page](https://github.com/nangohq/nango/tree/master/docs-v2/integrations/all/salesforce-cdp.mdx)
+
+## Going further
+
+
+ Guide to connect to Salesforce (Data Cloud) using Connect UI
+
+
diff --git a/docs-v2/integrations/all/salesforce-cdp/app_manager.png b/docs-v2/integrations/all/salesforce-cdp/app_manager.png
new file mode 100644
index 00000000000..aa93404686d
Binary files /dev/null and b/docs-v2/integrations/all/salesforce-cdp/app_manager.png differ
diff --git a/docs-v2/integrations/all/salesforce-cdp/connect.mdx b/docs-v2/integrations/all/salesforce-cdp/connect.mdx
new file mode 100644
index 00000000000..532514e7a27
--- /dev/null
+++ b/docs-v2/integrations/all/salesforce-cdp/connect.mdx
@@ -0,0 +1,118 @@
+---
+title: Salesforce (Data Cloud) - How do I link my account?
+sidebarTitle: Salesforce (Data Cloud)
+---
+
+# Overview
+
+To authenticate with Salesforce (Data Cloud), you need:
+1. **Encoded JWT** - A unique string that enables client applications to access Salesforce (Data Cloud) resources without requiring users to provide their credentials.
+
+This guide will walk you through generating your encoded JWT within Salesforce.
+
+### Prerequisites:
+
+- You must have a Salesforce account with an active Data Cloud subscription.
+
+### Instructions:
+
+#### Step 1: Generating your Encoded JWT
+- Interacting with the Data Cloud API requires a signed digital certificate. You can use a private key and certificate issued by a certification authority. Alternatively, you can use OpenSSL to create a key and a self-signed digital certificate. Here's how to create a self-signed certificate with OpenSSL.
+1. Run the following command to generate a 2048-bit RSA private key:
+
+```
+openssl genrsa 2048 > host.key && chmod 400 host.key
+```
+2. Use the private key to sign a certificate. Enter details about the certificate, or press Enter at each prompt to accept the default value:
+```
+openssl req -new -x509 -nodes -sha256 -days 365 -key host.key -out host.crt
+```
+- Now that you have created the above signed certificate, you will now need to create an app in Salesforce and upload the above signed certificate.
+3. Login to your salesforce account and In the Setup, in the Quick Find box, enter apps, and then select **App Manager**.
+
+4. Click **New Connected App**.
+5. For Connected App Name, enter an app name and your email address.
+6. Select **Enable OAuth Settings**.
+7. For **Callback URL**, enter `http://localhost:1717/OauthRedirect`.
+8. Select Use **digital signatures**, and then click **Browse**.
+9. Select your above self-signed certificate **(host.crt)**.
+10. Add the OAuth scopes that are necessary for your use case. For example, if your use case requires you to ingest content, add the **Manage Data Cloud Ingestion API data (cdp_ingest_api)** scope.
+11. Click **Save**.
+
+12. Click **Manage Consumer Details**.
+
+13. Copy the **Consumer Key** value. This value is also referred to as the **client ID**. You will use the client ID value when you are generaying your encoded (JWT) in the step below.
+8. Now that your certificate is registered with Salesforce, you need to generate an encoded JWT. You can use the code from the following script to generate your encoded JWT offline.
+```
+import { readFileSync } from 'fs';
+import jwt from 'jsonwebtoken';
+import readlineSync from 'readline-sync';
+
+const getUserInput = () => {
+ const clientId = readlineSync.question('Please enter your Salesforce client ID: ');
+ const username = readlineSync.question('Please enter your Salesforce username: ');
+ return { clientId, username };
+};
+
+const readPrivateKey = (path) => {
+ try {
+ return readFileSync(path, 'utf8');
+ } catch (error) {
+ console.error(`Error reading private key from ${path}:`, error);
+ throw error;
+ }
+};
+
+const createJwtClaims = (clientId, username) => {
+ const currentTime = Math.floor(Date.now() / 1000);
+ const expiry = currentTime + (10 * 365 * 24 * 60 * 60);
+
+ return {
+ iss: clientId,
+ sub: username,
+ aud: 'https://login.salesforce.com',
+ exp: expiry,
+ };
+};
+
+const generateJwtToken = (claims, privateKey) => {
+ try {
+ return jwt.sign(claims, privateKey, { algorithm: 'RS256' })
+ } catch (error) {
+ console.error('Error signing assertion:', error);
+ throw error;
+ }
+};
+
+const generateJwtAssertion = () => {
+ const { clientId, username } = getUserInput();
+ const privateKeyPath = 'host.key';
+
+ const privateKey = readPrivateKey(privateKeyPath);
+
+ const claims = createJwtClaims(clientId, username);
+
+ const token = generateJwtToken(claims, privateKey);
+
+ console.log('Generated Salesforce assertion:', token);
+};
+
+generateJwtAssertion();
+
+
+```
+- Run the script above in the same directory where your certificates were generated. It will prompt you for your **Client ID** obtained when creating a connected app and your **Username**, used when signing in to Salesforce. An encoded JWT will then be generated.
+
+**Note**: The generated **Encoded JWT** is valid for ten years. After this period, you will need to regenerate your encoded JWT and reauthenticate.
+
+#### Step 2: Enter credentials in the Connect UI
+
+Once you have your **Encoded JWT**:
+1. Open the form where you need to authenticate with Salesforce (Data Cloud).
+2. Enter your **Encoded JWT** in the designated field.
+3. Submit the form, and you should be successfully authenticated.
+
+
+
+You are now connected to Salesforce (Data Cloud).
+
\ No newline at end of file
diff --git a/docs-v2/integrations/all/salesforce-cdp/consumer_keys.png b/docs-v2/integrations/all/salesforce-cdp/consumer_keys.png
new file mode 100644
index 00000000000..56ea38a88e2
Binary files /dev/null and b/docs-v2/integrations/all/salesforce-cdp/consumer_keys.png differ
diff --git a/docs-v2/integrations/all/salesforce-cdp/creating_app.png b/docs-v2/integrations/all/salesforce-cdp/creating_app.png
new file mode 100644
index 00000000000..f5b349893f5
Binary files /dev/null and b/docs-v2/integrations/all/salesforce-cdp/creating_app.png differ
diff --git a/docs-v2/integrations/all/salesforce-cdp/form.png b/docs-v2/integrations/all/salesforce-cdp/form.png
new file mode 100644
index 00000000000..e9cdcaa680a
Binary files /dev/null and b/docs-v2/integrations/all/salesforce-cdp/form.png differ
diff --git a/docs-v2/mint.json b/docs-v2/mint.json
index 3a99ae541e2..dabb0c7456e 100644
--- a/docs-v2/mint.json
+++ b/docs-v2/mint.json
@@ -535,6 +535,7 @@
"integrations/all/sage-intacct",
"integrations/all/salesforce",
"integrations/all/salesforce-experience-cloud",
+ "integrations/all/salesforce-cdp",
"integrations/all/salesforce-sandbox",
"integrations/all/salesloft",
"integrations/all/sap-concur",
diff --git a/docs-v2/snippets/generated/salesforce-cdp/PreBuiltTooling.mdx b/docs-v2/snippets/generated/salesforce-cdp/PreBuiltTooling.mdx
new file mode 100644
index 00000000000..1828cd5ba1a
--- /dev/null
+++ b/docs-v2/snippets/generated/salesforce-cdp/PreBuiltTooling.mdx
@@ -0,0 +1,40 @@
+## Pre-built tooling
+
+
+| Tools | Status |
+| - | - |
+| Pre-built authorization (Two Step) | ✅ |
+| Pre-built authorization UI | ✅ |
+| Custom authorization UI | ✅ |
+| End-user authorization guide | ✅ |
+| Expired credentials detection | ✅ |
+
+
+| Tools | Status |
+| - | - |
+| Pre-built integrations | 🚫 (time to contribute: <48h) |
+| API unification | ✅ |
+| 2-way sync | ✅ |
+| Webhooks from Nango on data modifications | ✅ |
+| Real-time webhooks from 3rd-party API | 🚫 (time to contribute: <48h) |
+| Proxy requests | ✅ |
+
+
+| Tools | Status |
+| - | - |
+| HTTP request logging | ✅ |
+| End-to-type type safety | ✅ |
+| Data runtime validation | ✅ |
+| OpenTelemetry export | ✅ |
+| Slack alerts on errors | ✅ |
+| Integration status API | ✅ |
+
+
+| Tools | Status |
+| - | - |
+| Create or customize use-cases | ✅ |
+| Pre-configured pagination | 🚫 (time to contribute: <48h) |
+| Pre-configured rate-limit handling | 🚫 (time to contribute: <48h) |
+| Per-customer configurations | ✅ |
+
+
\ No newline at end of file
diff --git a/docs-v2/snippets/generated/salesforce-cdp/PreBuiltUseCases.mdx b/docs-v2/snippets/generated/salesforce-cdp/PreBuiltUseCases.mdx
new file mode 100644
index 00000000000..1529727d8af
--- /dev/null
+++ b/docs-v2/snippets/generated/salesforce-cdp/PreBuiltUseCases.mdx
@@ -0,0 +1,5 @@
+## Pre-built integrations
+
+_No pre-built integration yet (time to contribute: <48h)_
+
+Not seeing the integration you need? [Build your own](https://docs.nango.dev/guides/custom-integration-builder/overview) independently.
diff --git a/package-lock.json b/package-lock.json
index 48674f1eb85..0e85bffc1b0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18799,7 +18799,6 @@
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
diff --git a/packages/providers/providers.yaml b/packages/providers/providers.yaml
index 4ba012bc719..9265b59be2f 100644
--- a/packages/providers/providers.yaml
+++ b/packages/providers/providers.yaml
@@ -6610,7 +6610,44 @@ salesforce-experience-cloud:
format: uri
pattern: '^https?://.*$'
automated: true
-
+salesforce-cdp:
+ display_name: Salesforce (Data Cloud)
+ categories:
+ - storage
+ auth_mode: TWO_STEP
+ token_url: https://login.salesforce.com/services/oauth2/token
+ body_format: form
+ token_params:
+ grant_type: urn:ietf:params:oauth:grant-type:jwt-bearer
+ assertion: ${credential.jwt}
+ token_headers:
+ content-type: application/x-www-form-urlencoded
+ additional_steps:
+ - body_format: form
+ token_params:
+ grant_type: urn:salesforce:grant-type:external:cdp
+ subject_token: ${step1.access_token}
+ subject_token_type: urn:ietf:params:oauth:token-type:access_token
+ token_headers:
+ content-type: application/x-www-form-urlencoded
+ token_url: ${step1.instance_url}/services/a360/token
+ token_response_metadata:
+ - instance_url
+ proxy:
+ base_url: ${connectionConfig.instance_url}
+ token_response:
+ token: access_token
+ token_expiration: expires_in
+ token_expiration_strategy: expireIn
+ docs: https://docs.nango.dev/integrations/all/salesforce-cdp
+ docs_connect: https://docs.nango.dev/integrations/all/salesforce-cdp/connect
+ credentials:
+ jwt:
+ type: string
+ title: Encoded JWT
+ description: This is your pre-generated, encoded JSON Web Token (JWT)
+ secret: true
+ doc_section: '#step-1-generating-your-encoded-jwt'
sap-concur:
display_name: SAP Concur
categories:
diff --git a/packages/server/lib/controllers/auth/postTwoStep.ts b/packages/server/lib/controllers/auth/postTwoStep.ts
index 9e33a78f0cb..4f9e49f0592 100644
--- a/packages/server/lib/controllers/auth/postTwoStep.ts
+++ b/packages/server/lib/controllers/auth/postTwoStep.ts
@@ -12,7 +12,8 @@ import {
ErrorSourceEnum,
LogActionEnum,
getProvider,
- linkConnection
+ linkConnection,
+ getConnectionMetadataFromTokenResponse
} from '@nangohq/shared';
import type { PostPublicTwoStepAuthorization, ProviderTwoStep } from '@nangohq/types';
import type { LogContext } from '@nangohq/logs';
@@ -68,7 +69,7 @@ export const postPublicTwoStepAuthorization = asyncWrapper 0 ? (Object.fromEntries(arr) as Record) : {};
}
-/**
- * A helper function to extract the additional connection metadata returned from the Provider in the token response.
- * It can parse booleans or strings only
- */
-export function getConnectionMetadataFromTokenResponse(params: any, provider: Provider): Record {
- if (!params || !provider.token_response_metadata) {
- return {};
- }
-
- const whitelistedKeys = provider.token_response_metadata;
-
- const getValueFromDotNotation = (obj: any, key: string): any => {
- return key.split('.').reduce((o, k) => (o && o[k] !== undefined ? o[k] : undefined), obj);
- };
-
- // Filter out non-strings, non-booleans & non-whitelisted keys.
- const arr = Object.entries(params).filter(([k, v]) => {
- const isStringValueOrBoolean = typeof v === 'string' || typeof v === 'boolean';
- if (isStringValueOrBoolean && whitelistedKeys.includes(k)) {
- return true;
- }
- // Check for dot notation keys
- const dotNotationValue = getValueFromDotNotation(params, k);
- return isStringValueOrBoolean && whitelistedKeys.includes(dotNotationValue);
- });
-
- // Add support for dot notation keys
- const dotNotationArr = whitelistedKeys
- .map((key) => {
- const value = getValueFromDotNotation(params, key);
- const isStringValueOrBoolean = typeof value === 'string' || typeof value === 'boolean';
- return isStringValueOrBoolean ? [key, value] : null;
- })
- .filter(Boolean);
-
- const combinedArr: [string, any][] = [...arr, ...dotNotationArr].filter((item) => item !== null) as [string, any][];
-
- return combinedArr.length > 0 ? (Object.fromEntries(combinedArr) as Record) : {};
-}
-
export function parseConnectionConfigParamsFromTemplate(provider: Provider): string[] {
if (
provider.token_url ||
diff --git a/packages/server/lib/utils/utils.unit.test.ts b/packages/server/lib/utils/utils.unit.test.ts
index e62fb0b11ad..edbd8e14222 100644
--- a/packages/server/lib/utils/utils.unit.test.ts
+++ b/packages/server/lib/utils/utils.unit.test.ts
@@ -1,6 +1,5 @@
import { expect, describe, it } from 'vitest';
-import { getConnectionMetadataFromTokenResponse, parseConnectionConfigParamsFromTemplate, getAdditionalAuthorizationParams } from './utils.js';
-import type { Provider } from '@nangohq/types';
+import { parseConnectionConfigParamsFromTemplate, getAdditionalAuthorizationParams } from './utils.js';
describe('Utils unit tests', () => {
it('Should parse config params in authorization_url', () => {
@@ -134,73 +133,6 @@ describe('Utils unit tests', () => {
expect(params).toEqual(['some_domain']);
});
- it('Should extract metadata from token response based on provider', () => {
- const provider: Provider = {
- display_name: 'test',
- docs: '',
- auth_mode: 'OAUTH2',
- token_response_metadata: ['incoming_webhook.url', 'ok', 'bot_user_id', 'scope']
- };
-
- const params = {
- ok: true,
- scope: 'chat:write,channels:read,team.billing:read,users:read,channels:history,channels:join,incoming-webhook',
- token_type: 'bot',
- bot_user_id: 'abcd',
- enterprise: null,
- is_enterprise_install: false,
- incoming_webhook: {
- channel_id: 'foo',
- configuration_url: 'https://nangohq.slack.com',
- url: 'https://hooks.slack.com'
- }
- };
-
- const result = getConnectionMetadataFromTokenResponse(params, provider);
- expect(result).toEqual({
- 'incoming_webhook.url': 'https://hooks.slack.com',
- ok: true,
- bot_user_id: 'abcd',
- scope: 'chat:write,channels:read,team.billing:read,users:read,channels:history,channels:join,incoming-webhook'
- });
- });
-
- it('Should extract metadata from token response based on template and if it does not exist not fail', () => {
- const provider: Provider = {
- display_name: 'test',
- docs: '',
- auth_mode: 'OAUTH2',
- token_response_metadata: ['incoming_webhook.url', 'ok']
- };
-
- const params = {
- scope: 'chat:write,channels:read,team.billing:read,users:read,channels:history,channels:join,incoming-webhook',
- token_type: 'bot',
- enterprise: null,
- is_enterprise_install: false,
- incoming_webhook: {
- configuration_url: 'foo.bar'
- }
- };
-
- const result = getConnectionMetadataFromTokenResponse(params, provider);
- expect(result).toEqual({});
- });
-
- it('Should not extract metadata from an empty token response', () => {
- const provider: Provider = {
- display_name: 'test',
- docs: '',
- auth_mode: 'OAUTH2',
- token_response_metadata: ['incoming_webhook.url', 'ok']
- };
-
- const params = {};
-
- const result = getConnectionMetadataFromTokenResponse(params, provider);
- expect(result).toEqual({});
- });
-
it('Should return additional authorization params with string values only and preserve undefined values', () => {
const params = {
key1: 'value1',
diff --git a/packages/shared/lib/services/connection.service.ts b/packages/shared/lib/services/connection.service.ts
index 80877c5addb..a932f002e18 100644
--- a/packages/shared/lib/services/connection.service.ts
+++ b/packages/shared/lib/services/connection.service.ts
@@ -61,7 +61,10 @@ import {
interpolateObject,
extractValueByPath,
stripCredential,
- interpolateObjectValues
+ interpolateObjectValues,
+ stripStepResponse,
+ extractStepNumber,
+ getStepResponse
} from '../utils/utils.js';
import type { LogContext, LogContextGetter } from '@nangohq/logs';
import { CONNECTIONS_WITH_SCRIPTS_CAP_LIMIT } from '../constants.js';
@@ -1788,7 +1791,59 @@ class ConnectionService {
responseData = parser.parse(response.data);
}
- const parsedCreds = this.parseRawCredentials(responseData, 'TWO_STEP', provider) as TwoStepCredentials;
+ const stepResponses: any[] = [responseData];
+ if (provider.additional_steps) {
+ for (let stepIndex = 1; stepIndex <= provider.additional_steps.length; stepIndex++) {
+ const step = provider.additional_steps[stepIndex - 1];
+ if (!step) {
+ continue;
+ }
+
+ let stepPostBody: Record = {};
+
+ if (step.token_params) {
+ for (const [key, value] of Object.entries(step.token_params)) {
+ const stepNumber = extractStepNumber(value);
+ const stepResponsesObj = stepNumber !== null ? getStepResponse(stepNumber, stepResponses) : {};
+
+ const strippedValue = stripStepResponse(value, stepResponsesObj);
+ if (typeof strippedValue === 'object' && strippedValue !== null) {
+ stepPostBody[key] = interpolateObject(strippedValue, dynamicCredentials);
+ } else if (typeof strippedValue === 'string') {
+ stepPostBody[key] = interpolateString(strippedValue, dynamicCredentials);
+ } else {
+ stepPostBody[key] = strippedValue;
+ }
+ }
+ stepPostBody = interpolateObjectValues(stepPostBody, connectionConfig);
+ }
+
+ const stepNumberForURL = extractStepNumber(step.token_url);
+ const stepResponsesObjForURL = stepNumberForURL !== null ? getStepResponse(stepNumberForURL, stepResponses) : {};
+ const interpolatedTokenUrl = stripStepResponse(step.token_url, stepResponsesObjForURL);
+ const stepUrl = new URL(interpolatedTokenUrl).toString();
+
+ const stepBodyContent = bodyFormat === 'form' ? new URLSearchParams(stepPostBody).toString() : JSON.stringify(stepPostBody);
+
+ const stepHeaders: Record = {};
+
+ if (step.token_headers) {
+ for (const [key, value] of Object.entries(step.token_headers)) {
+ stepHeaders[key] = interpolateString(value, dynamicCredentials);
+ }
+ }
+
+ const stepRequestOptions = { headers: stepHeaders };
+ const stepResponse = await axios.post(stepUrl, stepBodyContent, stepRequestOptions);
+
+ if (stepResponse.status !== 200) {
+ return { success: false, error: new NangoError(`invalid_two_step_credentials_step_${stepIndex}`), response: null };
+ }
+
+ stepResponses.push(stepResponse.data);
+ }
+ }
+ const parsedCreds = this.parseRawCredentials(stepResponses[stepResponses.length - 1], 'TWO_STEP', provider) as TwoStepCredentials;
for (const [key, value] of Object.entries(dynamicCredentials)) {
if (value !== undefined) {
diff --git a/packages/shared/lib/utils/error.ts b/packages/shared/lib/utils/error.ts
index 69958535f8e..b12ae862647 100644
--- a/packages/shared/lib/utils/error.ts
+++ b/packages/shared/lib/utils/error.ts
@@ -374,6 +374,11 @@ export class NangoError extends Error {
this.message = `Error fetching Two Step credentials`;
break;
+ case 'invalid_two_step_credentials_second_request':
+ this.status = 400;
+ this.message = `Error fetching Two Step credentials in the second request`;
+ break;
+
case 'signature_token_generation_error':
this.status = 400;
this.message = `Error generating signature based token`;
diff --git a/packages/shared/lib/utils/utils.ts b/packages/shared/lib/utils/utils.ts
index 6e40d8ea66b..9e8887e9a13 100644
--- a/packages/shared/lib/utils/utils.ts
+++ b/packages/shared/lib/utils/utils.ts
@@ -2,7 +2,7 @@ import path from 'path';
import { fileURLToPath } from 'url';
import { isEnterprise, isStaging, isProd, localhostUrl, cloudHost, stagingHost } from '@nangohq/utils';
import get from 'lodash-es/get.js';
-import type { DBConnection } from '@nangohq/types';
+import type { Provider, DBConnection } from '@nangohq/types';
export enum UserType {
Local = 'localhost',
@@ -220,6 +220,39 @@ export function stripCredential(obj: any): any {
return obj;
}
+export function stripStepResponse(obj: any, step: Record): any {
+ if (typeof obj === 'string') {
+ return obj.replace(/\${step\d+\.(.*?)}/g, (_, key) => {
+ return step[key] || '';
+ });
+ } else if (typeof obj === 'object' && obj !== null) {
+ const strippedObject: any = {};
+ for (const [key, value] of Object.entries(obj)) {
+ strippedObject[key] = stripStepResponse(value, step);
+ }
+ return strippedObject;
+ }
+ return obj;
+}
+
+export function extractStepNumber(str: string): number | null {
+ const match = str.match(/\${step(\d+)\..*?}/);
+
+ if (match && match[1]) {
+ const stepNumber = parseInt(match[1], 10);
+ return stepNumber;
+ }
+
+ return null;
+}
+
+export function getStepResponse(stepNumber: number, stepResponses: any[]): Record {
+ if (stepResponses && stepResponses.length > stepNumber - 1 && stepResponses[stepNumber - 1]) {
+ return stepResponses[stepNumber - 1];
+ }
+ return {};
+}
+
export function extractValueByPath(obj: Record, path: string): any {
return get(obj, path);
}
@@ -276,3 +309,43 @@ export function getConnectionConfig(queryParams: any): Record {
export function encodeParameters(params: Record): Record {
return Object.fromEntries(Object.entries(params).map(([key, value]) => [key, encodeURIComponent(String(value))]));
}
+
+/**
+ * A helper function to extract the additional connection metadata returned from the Provider in the token response.
+ * It can parse booleans or strings only
+ */
+export function getConnectionMetadataFromTokenResponse(params: any, provider: Provider): Record {
+ if (!params || !provider.token_response_metadata) {
+ return {};
+ }
+
+ const whitelistedKeys = provider.token_response_metadata;
+
+ const getValueFromDotNotation = (obj: any, key: string): any => {
+ return key.split('.').reduce((o, k) => (o && o[k] !== undefined ? o[k] : undefined), obj);
+ };
+
+ // Filter out non-strings, non-booleans & non-whitelisted keys.
+ const arr = Object.entries(params).filter(([k, v]) => {
+ const isStringValueOrBoolean = typeof v === 'string' || typeof v === 'boolean';
+ if (isStringValueOrBoolean && whitelistedKeys.includes(k)) {
+ return true;
+ }
+ // Check for dot notation keys
+ const dotNotationValue = getValueFromDotNotation(params, k);
+ return isStringValueOrBoolean && whitelistedKeys.includes(dotNotationValue);
+ });
+
+ // Add support for dot notation keys
+ const dotNotationArr = whitelistedKeys
+ .map((key) => {
+ const value = getValueFromDotNotation(params, key);
+ const isStringValueOrBoolean = typeof value === 'string' || typeof value === 'boolean';
+ return isStringValueOrBoolean ? [key, value] : null;
+ })
+ .filter(Boolean);
+
+ const combinedArr: [string, any][] = [...arr, ...dotNotationArr].filter((item) => item !== null) as [string, any][];
+
+ return combinedArr.length > 0 ? (Object.fromEntries(combinedArr) as Record) : {};
+}
diff --git a/packages/shared/lib/utils/utils.unit.test.ts b/packages/shared/lib/utils/utils.unit.test.ts
index 6cb9f6834f1..cc23ebf9247 100644
--- a/packages/shared/lib/utils/utils.unit.test.ts
+++ b/packages/shared/lib/utils/utils.unit.test.ts
@@ -1,5 +1,6 @@
import { expect, describe, it } from 'vitest';
import * as utils from './utils.js';
+import type { Provider } from '@nangohq/types';
describe('Proxy service Construct Header Tests', () => {
it('Should correctly return true if the url is valid', () => {
@@ -71,3 +72,70 @@ describe('interpolateIfNeeded', () => {
expect(result).toBe('MyAppDetails');
});
});
+
+it('Should extract metadata from token response based on provider', () => {
+ const provider: Provider = {
+ display_name: 'test',
+ docs: '',
+ auth_mode: 'OAUTH2',
+ token_response_metadata: ['incoming_webhook.url', 'ok', 'bot_user_id', 'scope']
+ };
+
+ const params = {
+ ok: true,
+ scope: 'chat:write,channels:read,team.billing:read,users:read,channels:history,channels:join,incoming-webhook',
+ token_type: 'bot',
+ bot_user_id: 'abcd',
+ enterprise: null,
+ is_enterprise_install: false,
+ incoming_webhook: {
+ channel_id: 'foo',
+ configuration_url: 'https://nangohq.slack.com',
+ url: 'https://hooks.slack.com'
+ }
+ };
+
+ const result = utils.getConnectionMetadataFromTokenResponse(params, provider);
+ expect(result).toEqual({
+ 'incoming_webhook.url': 'https://hooks.slack.com',
+ ok: true,
+ bot_user_id: 'abcd',
+ scope: 'chat:write,channels:read,team.billing:read,users:read,channels:history,channels:join,incoming-webhook'
+ });
+});
+
+it('Should extract metadata from token response based on template and if it does not exist not fail', () => {
+ const provider: Provider = {
+ display_name: 'test',
+ docs: '',
+ auth_mode: 'OAUTH2',
+ token_response_metadata: ['incoming_webhook.url', 'ok']
+ };
+
+ const params = {
+ scope: 'chat:write,channels:read,team.billing:read,users:read,channels:history,channels:join,incoming-webhook',
+ token_type: 'bot',
+ enterprise: null,
+ is_enterprise_install: false,
+ incoming_webhook: {
+ configuration_url: 'foo.bar'
+ }
+ };
+
+ const result = utils.getConnectionMetadataFromTokenResponse(params, provider);
+ expect(result).toEqual({});
+});
+
+it('Should not extract metadata from an empty token response', () => {
+ const provider: Provider = {
+ display_name: 'test',
+ docs: '',
+ auth_mode: 'OAUTH2',
+ token_response_metadata: ['incoming_webhook.url', 'ok']
+ };
+
+ const params = {};
+
+ const result = utils.getConnectionMetadataFromTokenResponse(params, provider);
+ expect(result).toEqual({});
+});
diff --git a/packages/types/lib/providers/provider.ts b/packages/types/lib/providers/provider.ts
index 4ba776e8045..75fcc6aac70 100644
--- a/packages/types/lib/providers/provider.ts
+++ b/packages/types/lib/providers/provider.ts
@@ -135,6 +135,12 @@ export interface ProviderTwoStep extends Omit {
token_expiration: string;
token_expiration_strategy: 'expireAt' | 'expireIn';
};
+ additional_steps?: {
+ body_format?: 'json' | 'form';
+ token_params?: Record;
+ token_headers?: Record;
+ token_url: string;
+ }[];
token_expires_in_ms?: number;
proxy_header_authorization?: string;
body_format?: 'xml' | 'json' | 'form';
diff --git a/packages/webapp/public/images/template-logos/salesforce-cdp.svg b/packages/webapp/public/images/template-logos/salesforce-cdp.svg
new file mode 120000
index 00000000000..01e3dce2eeb
--- /dev/null
+++ b/packages/webapp/public/images/template-logos/salesforce-cdp.svg
@@ -0,0 +1 @@
+salesforce.svg
\ No newline at end of file
diff --git a/scripts/validation/providers/schema.json b/scripts/validation/providers/schema.json
index b33405471c9..a017afec018 100644
--- a/scripts/validation/providers/schema.json
+++ b/scripts/validation/providers/schema.json
@@ -333,6 +333,47 @@
"request_url": {
"type": "string"
},
+ "additional_steps": {
+ "type": "array",
+ "additionalProperties": true,
+ "properties": {
+ "body_format": {
+ "type": "string",
+ "enum": ["form"]
+ },
+ "token_params": {
+ "type": "object",
+ "properties": {
+ "grant_type": {
+ "type": "string",
+ "enum": ["urn:salesforce:grant-type:external:cdp"]
+ },
+ "subject_token": {
+ "type": "string"
+ },
+ "subject_token_type": {
+ "type": "string",
+ "enum": ["urn:ietf:params:oauth:token-type:access_token"]
+ }
+ },
+ "required": ["grant_type", "subject_token", "subject_token_type"]
+ },
+ "token_headers": {
+ "type": "object",
+ "properties": {
+ "content-type": {
+ "type": "string",
+ "enum": ["application/x-www-form-urlencoded"]
+ }
+ },
+ "required": ["content-type"]
+ },
+ "token_url": {
+ "type": "string"
+ }
+ },
+ "required": ["body_format", "token_params", "token_headers", "token_url"]
+ },
"scope_separator": {
"type": "string"
},
@@ -359,7 +400,12 @@
"properties": {
"grant_type": {
"type": "string",
- "enum": ["authorization_code", "client_credentials", "urn:ietf:params:oauth:grant-type:saml2-bearer"]
+ "enum": [
+ "authorization_code",
+ "client_credentials",
+ "urn:ietf:params:oauth:grant-type:saml2-bearer",
+ "urn:ietf:params:oauth:grant-type:jwt-bearer"
+ ]
},
"request": {
"anyOf": [