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": [