diff --git a/apps/docs/content/docs/en/integrations/salesforce-service-account.mdx b/apps/docs/content/docs/en/integrations/salesforce-service-account.mdx index 5c18f72f256..6ff28bf72b1 100644 --- a/apps/docs/content/docs/en/integrations/salesforce-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/salesforce-service-account.mdx @@ -1,15 +1,26 @@ --- title: Salesforce Integration Users -description: Set up a Salesforce External Client App with the Client Credentials Flow and a dedicated integration user to use Salesforce in Sim workflows +description: Set up a Salesforce External Client App with the Client Credentials Flow or the JWT Bearer Flow and a dedicated integration user to use Salesforce in Sim workflows --- import { Callout } from 'fumadocs-ui/components/callout' import { Step, Steps } from 'fumadocs-ui/components/steps' import { FAQ } from '@/components/ui/faq' -Salesforce's OAuth 2.0 Client Credentials Flow lets your workflows authenticate to Salesforce as a dedicated **integration user** instead of through a person's OAuth login. An admin creates an External Client App once, enables the flow, and picks the "Run As" user whose permissions every API call executes with — no user consent to expire, and data access that's governed entirely by that user's profile and permission sets. +Salesforce's server-to-server OAuth flows let your workflows authenticate as a dedicated **integration user** instead of through a person's OAuth login. An admin creates an External Client App once and names the integration user whose permissions every API call executes with — no user consent to expire, no browser session, and data access governed entirely by that user's profile and permission sets. -This is the recommended way to use Salesforce in production workflows: nothing depends on a person staying logged in, and what the credential can touch is exactly what the integration user can touch. +This is the recommended way to use Salesforce in production workflows: nothing depends on a person staying logged in, and what the credential can touch is exactly what the integration user can touch. It also works with an **API-only** user, who cannot sign in to the Salesforce UI at all and therefore cannot complete the interactive OAuth flow. + +Sim supports both server-to-server flows. Pick one: + +| | **Client credentials** | **JWT bearer** | +|---|---|---| +| What Sim stores | Consumer key + **consumer secret** | Consumer key + **private key** | +| Who calls run as | The app's **Run As** user | The **username** you name in Sim | +| Salesforce setup | Enable Client Credentials Flow, set Run As | Upload a certificate, pre-authorize the user | +| Pick it when | You want the simplest setup | Your security policy forbids shared secrets, or you want one app to serve several integration users | + +Both are equivalent in what they can do — every Salesforce tool works on either, subject to the integration user's permissions. ## Prerequisites @@ -54,7 +65,10 @@ External Client Apps are Salesforce's current-generation connected apps and the Add the OAuth scopes **Manage user data via APIs (api)** and **Access unique user identifiers (openid)** — `api` is required for the flow, and `openid` lets Sim look up the integration user's name via the userinfo endpoint (the instance URL comes back in the token response itself) - Enable the **Client Credentials Flow** in the OAuth settings, acknowledge the warning, and create the app + **Client credentials only:** enable the **Client Credentials Flow** in the OAuth settings and acknowledge the warning. Skip this if you're setting up JWT bearer — that flow has its own toggle (see below) + + + Create the app {/* TODO(screenshot): OAuth settings with Enable Client Credentials Flow checked */} @@ -105,6 +119,39 @@ If you already have a classic Connected App, it keeps working and the credential +### Using the JWT Bearer Flow Instead + +The JWT Bearer Flow authenticates with an uploaded certificate rather than a shared secret, and runs as a username you name in Sim rather than the app's Run As user. Set this up **instead of** steps 3 and 4 above. Steps 1, 2, and 5 are the same — but in step 2, skip the Client Credentials Flow substep; JWT bearer does not use that flow. + + + + Generate a key pair. `server.key` stays with Sim; `server.crt` is uploaded to Salesforce: + + ```bash + openssl req -x509 -sha256 -nodes -days 3650 -newkey rsa:2048 \ + -keyout server.key -out server.crt -subj "/CN=sim-salesforce" + ``` + + Keep `server.key` somewhere safe — Sim stores it encrypted and never shows it again. + + + In **External Client App Manager** → your app → **Edit Settings** → **OAuth Settings**, enable the **JWT Bearer Flow** toggle and upload `server.crt`. On a legacy Connected App the equivalent is **Use digital signatures** with the same file + + + Edit the app's **Policies** → **OAuth Policies** and set **Permitted Users** to **Admin approved users are pre-authorized** + + + This step is mandatory. The JWT flow requires prior approval of the app, and an API-only integration user can never grant that approval interactively — there is no UI for them to log in to. Without pre-authorization every token request fails with `user hasn't approved this consumer`. + + + + Assign the integration user's **profile** or a **permission set** to the app (External Client App Manager → your app → **Policies** → manage profiles/permission sets), so that user is covered by the pre-authorization + + + Copy the **Consumer Key** as in step 4. There is no consumer secret to copy — the JWT flow doesn't use one + + + ### 5. Find Your My Domain Host Go to **Setup** and search for **My Domain**. The host is required — Salesforce rejects the Client Credentials Flow at `login.salesforce.com` and `test.salesforce.com`. Depending on your org type it looks like: @@ -113,7 +160,11 @@ Go to **Setup** and search for **My Domain**. The host is required — Salesforc - **Sandbox:** `yourorg--sandboxname.sandbox.my.salesforce.com` - **Developer Edition:** `yourorg-dev-ed.develop.my.salesforce.com` -Sim also accepts other partitioned My Domain hosts (`scratch`, `demo`, `patch`, `trailblaze`, `free`). Government and military domains (`*.my.salesforce.mil`) aren't currently supported. +Sim also accepts other partitioned My Domain hosts (`scratch`, `demo`, `patch`, `trailblaze`, `free`). + + +Use the `my.salesforce.com` host, not the `my.salesforce-setup.com` host you see in the address bar while working in Setup, and not `lightning.force.com`. Government Cloud Plus domains (`*.my.salesforce.mil`) are not currently supported. + ## Permissions Instead of Scopes @@ -137,12 +188,17 @@ A permissions gap surfaces at run time as a Salesforce API error; fix it on the {/* TODO(screenshot): Salesforce integration page with the Add integration user app connect option */} - In the **Add Salesforce integration user app** dialog, paste the **Consumer key**, **Consumer secret**, and **My Domain host** (e.g. `yourorg.my.salesforce.com`), and optionally set a display name and description + In the **Add Salesforce integration user app** dialog, pick the **Authentication method** you configured. The dialog then asks only for that flow's fields: + + - **Client credentials** — **Consumer key** and **Consumer secret** + - **JWT bearer** — **Consumer key**, the **Private key** (paste the whole `server.key` file, `-----BEGIN` line included), and the **Run as username** (the integration user's Salesforce username, e.g. `integration.user@yourorg.com`) + + Then paste your **My Domain host** (e.g. `yourorg.my.salesforce.com`) and optionally set a display name and description - {/* TODO(screenshot): Add Salesforce integration user app dialog with all three fields filled in */} + {/* TODO(screenshot): Add Salesforce integration user app dialog with the authentication method selector */} - Click **Add integration user app**. Sim verifies the credentials by minting a real access token against your My Domain host. A host that doesn't resolve gets its own error message; a bad consumer key or secret and a flow that isn't fully configured both surface as a general authentication error — re-check all three values and the app's Client Credentials Flow policies. + Click **Add integration user app**. Sim verifies the credentials by minting a real access token against your My Domain host. A host that doesn't resolve gets its own error message; every other misconfiguration surfaces as a general authentication error — re-check the values and the app's OAuth policies. @@ -156,19 +212,25 @@ The block calls your org's REST API with a freshly minted access token — the s ## Token Behavior -Access tokens from this flow have no fixed lifetime in the response — an opaque token stays valid until the Run As user's session times out (2 hours by default; configurable from 15 minutes to 24 hours in Session Settings). There is no refresh token; Sim mints a new token whenever one is needed, so session timeouts are invisible to your workflows. +Access tokens from both flows have no fixed lifetime in the response — an opaque token stays valid until the Run As user's session times out (2 hours by default; configurable from 15 minutes to 24 hours in Session Settings). There is no refresh token; Sim mints a new token whenever one is needed, so session timeouts are invisible to your workflows. -Deactivating or freezing the Run As user stops all token minting with an `invalid_grant` error, halting every workflow that uses the credential. Password policies that expire the user's API access have the same effect. Treat the integration user as production infrastructure. +Deactivating or freezing the integration user — the Run As user for client credentials, or the run-as username for JWT bearer — stops all token minting with an `invalid_grant` error, halting every workflow that uses the credential. Password policies that expire the user's API access have the same effect. Treat the integration user as production infrastructure. diff --git a/apps/sim/app/api/auth/oauth/credentials/route.ts b/apps/sim/app/api/auth/oauth/credentials/route.ts index a3ff4c12097..bcff7e3b7e3 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.ts @@ -2,7 +2,7 @@ import { db } from '@sim/db' import { account, credential, credentialMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' -import { and, eq, isNotNull } from 'drizzle-orm' +import { and, eq, inArray, isNotNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { oauthCredentialsQuerySchema } from '@/lib/api/contracts/credentials' import { getValidationErrorMessage } from '@/lib/api/server' @@ -14,6 +14,7 @@ import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth' import { getCanonicalScopesForProvider, getServiceAccountProviderForProviderId, + providerIdsForService, } from '@/lib/oauth/utils' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -241,7 +242,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { and( eq(credential.workspaceId, effectiveWorkspaceId), eq(credential.type, 'oauth'), - eq(account.providerId, providerParam), + inArray(account.providerId, providerIdsForService(providerParam)), requesterCanAdmin ? undefined : isNotNull(credentialMember.id) ) ) diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.ts b/apps/sim/app/api/auth/oauth/disconnect/route.ts index 44840b2d568..c3c145e60e7 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.ts @@ -10,6 +10,7 @@ import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteCredential } from '@/lib/credentials/deletion' +import { providerIdsForService } from '@/lib/oauth/utils' import { captureServerEvent } from '@/lib/posthog/server' export const dynamic = 'force-dynamic' @@ -61,7 +62,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ? and(eq(account.userId, session.user.id), eq(account.providerId, providerId)) : and( eq(account.userId, session.user.id), - or(eq(account.providerId, provider), like(account.providerId, `${provider}-%`)) + or( + // The prefix sweep already caught `{base}-{feature}` ids by + // accident; an alternate authorization server shares that shape, + // so name it explicitly rather than relying on the accident. + inArray(account.providerId, providerIdsForService(provider)), + like(account.providerId, `${provider}-%`) + ) ) const targetAccounts = await db.select({ id: account.id }).from(account).where(accountFilter) diff --git a/apps/sim/app/api/auth/oauth/token/route.test.ts b/apps/sim/app/api/auth/oauth/token/route.test.ts index e1ef6105675..2abe76372bb 100644 --- a/apps/sim/app/api/auth/oauth/token/route.test.ts +++ b/apps/sim/app/api/auth/oauth/token/route.test.ts @@ -547,3 +547,64 @@ describe('OAuth Token API Routes', () => { }) }) }) + +describe('Salesforce instance URL resolution', () => { + const INSTANCE = 'https://acme--sbx.sandbox.my.salesforce.com' + + beforeEach(() => { + vi.clearAllMocks() + authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValue(null) + mockAuthorizeCredentialUse.mockResolvedValue({ + ok: true, + authType: 'session', + requesterUserId: 'test-user-id', + credentialOwnerUserId: 'owner-user-id', + }) + authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockResolvedValue({ + accessToken: 'fresh-token', + refreshed: false, + }) + }) + + /** + * The org host is smuggled through `scope` because the token response has + * nowhere to put it; the tools read it back as their `instanceUrl` param. + */ + function credentialForProvider(providerId: string) { + return { + id: 'credential-id', + accessToken: 'test-token', + refreshToken: 'refresh-token', + accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), + providerId, + scope: `__sf_instance__:${INSTANCE} api refresh_token openid`, + } + } + + it.each(['salesforce', 'salesforce-sandbox'])( + 'returns the stored instance URL for a %s credential', + async (providerId) => { + authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce( + credentialForProvider(providerId) + ) + + const response = await POST(createMockRequest('POST', { credentialId: 'credential-id' })) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.instanceUrl).toBe(INSTANCE) + } + ) + + it('omits instanceUrl for a non-Salesforce provider carrying a lookalike scope', async () => { + authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({ + ...credentialForProvider('google'), + }) + + const response = await POST(createMockRequest('POST', { credentialId: 'credential-id' })) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.instanceUrl).toBeUndefined() + }) +}) diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts index 302898717d0..ac7bcc1a976 100644 --- a/apps/sim/app/api/auth/oauth/token/route.ts +++ b/apps/sim/app/api/auth/oauth/token/route.ts @@ -12,6 +12,7 @@ import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' +import { extractSalesforceInstanceUrl, isSalesforceOAuthProviderId } from '@/lib/oauth/salesforce' import { captureServerEvent } from '@/lib/posthog/server' import { getCredential, @@ -26,11 +27,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('OAuthTokenAPI') -const SALESFORCE_INSTANCE_URL_REGEX = /__sf_instance__:([^\s]+)/ -// Stop at a comma or whitespace: better-auth persists Zoho's scopes comma-joined -// (no spaces), so a greedy `\S+` would swallow the whole scope list into the host. -// The Desk base URL itself never contains a comma or space. - /** * Get an access token for a specific credential * Supports both session-based authentication (for client-side requests) @@ -287,13 +283,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - let instanceUrl: string | undefined - if (credential.providerId === 'salesforce' && credential.scope) { - const instanceMatch = credential.scope.match(SALESFORCE_INSTANCE_URL_REGEX) - if (instanceMatch) { - instanceUrl = instanceMatch[1] - } - } + const instanceUrl = isSalesforceOAuthProviderId(credential.providerId) + ? extractSalesforceInstanceUrl(credential.scope) + : undefined // Zoho Desk persists its data-center-specific REST base URL in the scope // string (derived from the token response api_domain) so callers never @@ -410,14 +402,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) } - // For Salesforce, extract instanceUrl from the scope field - let instanceUrl: string | undefined - if (credential.providerId === 'salesforce' && credential.scope) { - const instanceMatch = credential.scope.match(SALESFORCE_INSTANCE_URL_REGEX) - if (instanceMatch) { - instanceUrl = instanceMatch[1] - } - } + const instanceUrl = isSalesforceOAuthProviderId(credential.providerId) + ? extractSalesforceInstanceUrl(credential.scope) + : undefined // Zoho Desk persists its data-center-specific REST base URL in the scope // string (derived from the token response api_domain) so callers never diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index b004e1d3148..019840f5643 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -466,7 +466,8 @@ function secretFingerprintOf(encryptedServiceAccountKey: string): string { /** * Resolves a client-credential service-account credential to a short-lived - * access token: decrypts the stored client id/secret + org id and mints via + * access token: decrypts the stored credential material (client id + secret, + * or the private key and run-as username for key-based grants) and mints via * the provider's registered minter (skipping the connect-time identity * lookup), read-through the per-instance cache. Wrapped in `coalesceLocally` * so concurrent block executions on one instance share a single mint. @@ -526,6 +527,9 @@ async function resolveClientCredentialAccountToken( clientSecret: blob.clientSecret, orgId: blob.orgId, dataCenter: blob.dataCenter, + authMethod: blob.authMethod, + privateKey: blob.privateKey, + username: blob.username, }, { skipIdentity: true } ) diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts index 69f7b3a3a60..54f49a5e29f 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts @@ -37,6 +37,19 @@ vi.mock('@/lib/credentials/access', () => ({ vi.mock('@/lib/oauth/utils', () => ({ getAllOAuthServices: vi.fn(() => [{ providerId: 'google-email', name: 'Gmail' }]), + // Real implementation: a credential id matches its service's OAuth id, an + // alternate authorization server, or the family's service-account id. + credentialProviderMatchesService: ( + credentialProviderId: string, + service: { + providerId: string + serviceAccountProviderId?: string + additionalProviderIds?: readonly string[] + } + ) => + service.providerId === credentialProviderId || + service.serviceAccountProviderId === credentialProviderId || + (service.additionalProviderIds?.includes(credentialProviderId) ?? false), })) import { GET } from '@/app/api/auth/oauth2/authorize/route' diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index 87a16bf9a46..71ac1518ab9 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -90,6 +90,9 @@ export const PUT = withRouteHandler( clientSecret: body.clientSecret, orgId: body.orgId, dataCenter: body.dataCenter, + authMethod: body.authMethod, + privateKey: body.privateKey, + username: body.username, request, }) if (!result.success) { diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index 71b897f27bb..f614398f0fa 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -30,7 +30,7 @@ import { } from '@/lib/credentials/service-account-secret' import { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service-accounts/descriptors' import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' -import { getServiceConfigByProviderId } from '@/lib/oauth' +import { getServiceConfigByProviderId, providerIdsForService } from '@/lib/oauth' import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' import { captureServerEvent } from '@/lib/posthog/server' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -237,7 +237,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { whereClauses.push(eq(credential.type, type)) } if (providerId) { - whereClauses.push(eq(credential.providerId, providerId)) + whereClauses.push(inArray(credential.providerId, providerIdsForService(providerId))) } const isWorkspaceAdmin = workspaceAccess.canAdmin @@ -331,6 +331,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { clientSecret, orgId, dataCenter, + authMethod, + privateKey, + username, } = parsed.data.body const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id) @@ -392,6 +395,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { clientSecret, orgId, dataCenter, + authMethod, + privateKey, + username, }) resolvedProviderId = secret.providerId resolvedAccountId = null diff --git a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx index 337bb878c29..a5bd8cdf51a 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx @@ -25,7 +25,7 @@ import { type OAuthProvider, parseProvider, } from '@/lib/oauth' -import { getScopeDescription } from '@/lib/oauth/utils' +import { getScopeDescription, getServiceConfigByProviderId } from '@/lib/oauth/utils' import { useCreateCredentialDraft, useWorkspaceCredentials } from '@/hooks/queries/credentials' import { useConnectOAuthService } from '@/hooks/queries/oauth/oauth-connections' @@ -129,11 +129,34 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { const { open, onOpenChange, mode } = props const isConnect = mode === 'connect' - const providerId = useMemo( + const declaredProviderId = useMemo( () => props.providerId ?? (props.serviceId ? getProviderIdFromServiceId(props.serviceId) : ''), [props.providerId, props.serviceId] ) + /** + * Authorization servers this service can be connected through, when it has + * more than one (Salesforce production vs sandbox). Offered on connect only: + * a reauthorize must return to the server that issued the credential. + */ + const { authServerOptions, authServerHint } = useMemo(() => { + const service = isConnect ? getServiceConfigByProviderId(declaredProviderId) : null + const labels = service?.providerIdLabels + if (!service?.additionalProviderIds?.length || !labels) { + return { authServerOptions: [], authServerHint: undefined } + } + return { + authServerOptions: [service.providerId, ...service.additionalProviderIds].map((value) => ({ + value, + label: labels[value] ?? value, + })), + authServerHint: service.providerIdPickerHint, + } + }, [isConnect, declaredProviderId]) + + const [selectedProviderId, setSelectedProviderId] = useState(null) + const providerId = selectedProviderId ?? declaredProviderId + const [displayName, setDisplayName] = useState('') const [description, setDescription] = useState('') const [validationError, setValidationError] = useState(null) @@ -202,6 +225,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { useEffect(() => { if (!open) { prefilled.current = false + setSelectedProviderId(null) return } if (!isConnect || prefilled.current || credentialsLoading) return @@ -329,6 +353,18 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {

)} + {authServerOptions.length > 0 && ( + + )} + {isConnect && ( (null) const credentialTarget = useMemo( - () => ({ providerId, baseProviderId, credentialId: reconnectCredentialId }), + () => ({ + providerId, + baseProviderId, + credentialId: reconnectCredentialId, + // A credential from an alternate authorization server (Salesforce + // sandbox) still connects this chip's service; without these the chip + // reads as disconnected and re-prompts a user who is already connected. + additionalProviderIds: providerIdsForService(providerId), + }), [baseProviderId, providerId, reconnectCredentialId] ) const credentialScope = `${workspaceId}:${providerId}:${reconnectCredentialId ?? ''}` @@ -471,6 +479,7 @@ export function useOAuthChipConnection({ workspaceId, providerId, baseProviderId, + additionalProviderIds: credentialTarget.additionalProviderIds, displayName, controlId, credentialId: reconnectCredentialId, diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx index f48df8e4bff..4dbbc05545d 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx @@ -8,13 +8,17 @@ import { ChipModalField, ChipModalFooter, ChipModalHeader, + ChipTextarea, SecretInput, } from '@sim/emcn' import { createLogger } from '@sim/logger' import { isApiClientError } from '@/lib/api/client/errors' -import type { - ClientCredentialAccountDescriptor, - ClientCredentialAccountField, +import { + AUTH_METHOD_FIELD_ID, + type ClientCredentialAccountDescriptor, + type ClientCredentialAccountField, + type ClientCredentialAccountFieldId, + partitionClientCredentialFields, } from '@/lib/credentials/client-credential-accounts/descriptors' import { useCreateWorkspaceCredential, @@ -32,13 +36,13 @@ const FALLBACK_ERROR_MESSAGE = "We couldn't add this credential. Try again in a */ function messageForClientCredentialError( err: unknown, - descriptor: ClientCredentialAccountDescriptor + descriptor: ClientCredentialAccountDescriptor, + requiredFields: ClientCredentialAccountField[] ): string { if (isApiClientError(err) && err.code) { - const fieldLabels = descriptor.fields - .filter((field) => !field.optional) - .map((field) => field.label) - .join(', ') + // Names the fields the *selected* auth method needed, so a JWT failure + // doesn't tell the user to check a consumer secret they never entered. + const fieldLabels = requiredFields.map((field) => field.label).join(', ') switch (err.code) { case 'invalid_credentials': return `We couldn't authenticate with those credentials. Check that the ${fieldLabels} all belong to the same ${descriptor.serviceLabel} app and that the app is authorized.` @@ -60,6 +64,8 @@ function messageForClientCredentialError( return FALLBACK_ERROR_MESSAGE } +type FieldValues = Partial> + function openDocs(url: string): void { window.open(url, '_blank', 'noopener,noreferrer') } @@ -80,13 +86,17 @@ interface ClientCredentialAccountModalProps { } /** - * Generic connect modal for client-credentials service accounts (Zoom - * Server-to-Server OAuth, Box CCG). Renders the client id, client secret, and - * org-identifier fields declared by the provider's - * {@link ClientCredentialAccountDescriptor} and submits through the same - * create/update credential mutations as the other service-account modals. - * The server verifies the triple by minting a real access token; failures are - * mapped from the route's `error.code`. + * Generic connect modal for client-credential service accounts (Zoom + * Server-to-Server OAuth, Box CCG, Salesforce). Renders the fields declared by + * the provider's {@link ClientCredentialAccountDescriptor}, in descriptor + * order, and submits through the same create/update credential mutations as + * the other service-account modals. The server verifies the credential by + * minting a real access token; failures are mapped from the route's + * `error.code`. + * + * A descriptor offering more than one grant declares an `authMethod` field; + * selecting a method shows only that branch's fields and gates submit on that + * branch's requirements, mirroring the server-side secret builder. */ export function ClientCredentialAccountModal({ open, @@ -100,10 +110,7 @@ export function ClientCredentialAccountModal({ initialDescription, onCreated, }: ClientCredentialAccountModalProps) { - const [clientId, setClientId] = useState('') - const [clientSecret, setClientSecret] = useState('') - const [orgId, setOrgId] = useState('') - const [dataCenter, setDataCenter] = useState('') + const [values, setValues] = useState({}) const [displayName, setDisplayName] = useState(initialDisplayName ?? '') const [description, setDescription] = useState(initialDescription ?? '') const [error, setError] = useState(null) @@ -113,32 +120,51 @@ export function ClientCredentialAccountModal({ useEffect(() => { if (open) return - setClientId('') - setClientSecret('') - setOrgId('') - setDataCenter('') + setValues({}) setDisplayName(initialDisplayName ?? '') setDescription(initialDescription ?? '') setError(null) }, [open, initialDisplayName, initialDescription]) - const clientIdField = descriptor.fields.find((field) => field.id === 'clientId') - const clientSecretField = descriptor.fields.find((field) => field.id === 'clientSecret') - const orgIdField = descriptor.fields.find((field) => field.id === 'orgId') - const dataCenterField = descriptor.fields.find((field) => field.id === 'dataCenter') + const authMethodField = descriptor.fields.find((field) => field.id === AUTH_METHOD_FIELD_ID) + /** + * A reconnect cannot pre-select the grant: the stored method lives inside the + * encrypted blob and is never returned to the client, so the admin restates it + * — they are retyping the secret anyway. Leaving it unset hides every + * branch-specific field, so the selector is the only thing left to fill. + */ + const mustRestateAuthMethod = Boolean(credentialId && !values.authMethod && authMethodField) + + const { visible, required } = partitionClientCredentialFields(descriptor, values.authMethod) + const visibleFields = mustRestateAuthMethod + ? visible.filter((field) => !field.requiredForAuthMethods) + : visible + // Markers reflect the descriptor's real requirements, so `clientId` and the + // host keep their asterisk while the method is unset — plus the picker itself + // while it is the thing blocking submit, so the greyed button has a visible + // cause. + const requiredFieldIds = new Set(required.map((field) => field.id)) + if (mustRestateAuthMethod) requiredFieldIds.add(AUTH_METHOD_FIELD_ID) + /** + * On a create the form already behaves as the descriptor's default grant, so + * the picker shows it rather than an empty placeholder implying no choice. + */ + const displayedAuthMethod = mustRestateAuthMethod + ? undefined + : (values.authMethod ?? descriptor.defaultAuthMethod) + const missingRequired = + mustRestateAuthMethod || required.some((field) => !values[field.id]?.trim()) - const trimmedClientId = clientId.trim() - const trimmedClientSecret = clientSecret.trim() - const trimmedOrgId = orgId.trim() - const trimmedDataCenter = dataCenter.trim() const isPending = createCredential.isPending || updateCredential.isPending - const isDisabled = !trimmedClientId || !trimmedClientSecret || !trimmedOrgId || isPending + const isDisabled = missingRequired || isPending - const hintFor = ( - field: ClientCredentialAccountField | undefined, - value: string - ): string | undefined => { - if (!field?.hintPattern || !field.hintMessage || value.length === 0) return undefined + const setField = (id: ClientCredentialAccountFieldId, value: string) => { + setValues((current) => ({ ...current, [id]: value })) + if (error) setError(null) + } + + const hintFor = (field: ClientCredentialAccountField, value: string): string | undefined => { + if (!field.hintPattern || !field.hintMessage || value.length === 0) return undefined const normalized = field.hintNormalize ? field.hintNormalize(value) : value return field.hintPattern.test(normalized) ? undefined : field.hintMessage } @@ -148,11 +174,13 @@ export function ClientCredentialAccountModal({ if (isDisabled) return try { let connectedCredentialId = credentialId - const secretFields = { - clientId: trimmedClientId, - clientSecret: trimmedClientSecret, - orgId: trimmedOrgId, - dataCenter: trimmedDataCenter || undefined, + // Only the fields the selected auth method actually uses are submitted; + // a value typed before switching methods must not ride along and end up + // stored on a credential whose grant never reads it. + const secretFields: FieldValues = {} + for (const field of visibleFields) { + const value = values[field.id]?.trim() + if (value) secretFields[field.id] = value } if (credentialId) { await updateCredential.mutateAsync({ @@ -175,7 +203,7 @@ export function ClientCredentialAccountModal({ if (connectedCredentialId) onCreated?.(connectedCredentialId) onOpenChange(false) } catch (err: unknown) { - setError(messageForClientCredentialError(err, descriptor)) + setError(messageForClientCredentialError(err, descriptor, required)) logger.error(`Failed to add ${descriptor.serviceLabel} service account credential`, err) } } @@ -190,77 +218,110 @@ export function ClientCredentialAccountModal({ Add {serviceName} {descriptor.connectNoun} - {clientIdField && ( - { - setClientId(value) - if (error) setError(null) - }} - placeholder={clientIdField.placeholder} - autoComplete='off' - required - hint={hintFor(clientIdField, trimmedClientId) ?? clientIdField.hint} - /> - )} + {visibleFields.map((field) => { + const value = values[field.id] ?? '' + const required = requiredFieldIds.has(field.id) + // helpText lands on the org identifier, not on a secret: every + // provider's caveat qualifies the org identifier or what the + // credential can reach (Box's Admin Console authorization, Zoom's + // Account ID, Salesforce's run-as user), never the secret being + // pasted. A live format hint still wins, matching the token modal's + // precedence. + const hint = + hintFor(field, value.trim()) ?? + field.hint ?? + (field.id === 'orgId' ? descriptor.helpText : undefined) - {clientSecretField && ( - - { - setClientSecret(value) - if (error) setError(null) - }} - placeholder={clientSecretField.placeholder} - name={`${descriptor.providerId}_client_secret`} - autoComplete='new-password' - autoCorrect='off' - autoCapitalize='off' - data-lpignore='true' - data-form-type='other' - /> - - )} + if (field.options) { + return ( + setField(field.id, next)} + options={field.options} + placeholder={field.placeholder} + align='start' + required={required} + hint={hint} + /> + ) + } - {orgIdField && ( - { - setOrgId(value) - if (error) setError(null) - }} - placeholder={orgIdField.placeholder} - autoComplete='off' - required - // helpText lands here, not on the client secret: every provider's - // caveat qualifies the org identifier or what the credential can - // reach (Box's Admin Console authorization, Zoom's Account ID, - // Salesforce's Run As user), never the secret being pasted. A live - // format hint still wins, matching the token modal's precedence. - hint={hintFor(orgIdField, trimmedOrgId) ?? orgIdField.hint ?? descriptor.helpText} - /> - )} + if (field.secret && field.multiline) { + return ( + + {(aria) => ( + setField(field.id, event.target.value)} + placeholder={field.placeholder} + className='min-h-[120px] font-mono' + // Browser spell-check and autofill ship textarea contents to + // third-party services — an exfiltration route for a pasted + // private key. `ChipModalField type='textarea'` exposes none + // of these, which is why this branch drops to `custom`. + spellCheck={false} + autoComplete='off' + autoCorrect='off' + autoCapitalize='off' + data-lpignore='true' + data-form-type='other' + /> + )} + + ) + } - {dataCenterField?.options && ( - { - setDataCenter(value) - if (error) setError(null) - }} - options={dataCenterField.options} - placeholder={dataCenterField.placeholder} - align='start' - hint={dataCenterField.hint} - /> - )} + if (field.secret) { + return ( + + {(aria) => ( + setField(field.id, next)} + placeholder={field.placeholder} + name={`${descriptor.providerId}_${field.id}`} + autoComplete='new-password' + autoCorrect='off' + autoCapitalize='off' + data-lpignore='true' + data-form-type='other' + /> + )} + + ) + } + + return ( + setField(field.id, next)} + placeholder={field.placeholder} + autoComplete='off' + required={required} + hint={hint} + /> + ) + })} )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx index d26d7599874..aa51770bbed 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx @@ -292,6 +292,10 @@ export function ToolCredentialSelector({ requiredScopes={getCanonicalScopesForProvider(effectiveProviderId)} newScopes={missingRequiredScopes} serviceId={serviceId} + // A reauthorize must return to the authorization server that issued + // the credential — deriving it from the service id would send a + // sandbox user to production, where they cannot sign in at all. + providerId={selectedCredential?.provider ?? effectiveProviderId} /> )} diff --git a/apps/sim/connectors/salesforce/salesforce.ts b/apps/sim/connectors/salesforce/salesforce.ts index eb99a6ddbb9..c5dcce56c65 100644 --- a/apps/sim/connectors/salesforce/salesforce.ts +++ b/apps/sim/connectors/salesforce/salesforce.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' +import { SALESFORCE_LOGIN_HOSTS } from '@/lib/oauth/salesforce' import { salesforceConnectorMeta } from '@/connectors/salesforce/meta' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' import { htmlToPlainText, parseTagDate } from '@/connectors/utils' @@ -11,8 +12,13 @@ const logger = createLogger('SalesforceConnector') * Salesforce serves the userinfo endpoint at the org's authentication host. * Tokens issued at test.salesforce.com (sandbox) are rejected at login.salesforce.com, * so we try each host in order and cache the working one in syncContext. + * + * Derived from the shared connector host map so a new authorization server + * reaches this probe automatically. The connector receives only an access + * token — not the credential's provider id — so it cannot pick the host + * up front the way the OAuth token route can. */ -const USERINFO_HOSTS = ['https://login.salesforce.com', 'https://test.salesforce.com'] as const +const USERINFO_HOSTS = Object.values(SALESFORCE_LOGIN_HOSTS).map((host) => `https://${host}`) const USERINFO_PATH = '/services/oauth2/userinfo' const API_VERSION = 'v62.0' const PAGE_SIZE = 200 diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts index a284acaeb12..ca094a48c24 100644 --- a/apps/sim/hooks/queries/credentials.ts +++ b/apps/sim/hooks/queries/credentials.ts @@ -136,21 +136,13 @@ export function useUpdateWorkspaceCredential() { credentialId: string } & ContractBodyInput ) => { + // Forward the whole contract body rather than re-listing its fields: a + // hand-maintained allowlist silently drops any field added to the + // contract later, and the payload type makes that invisible to `tsc`. + const { credentialId, ...body } = payload return requestJson(updateWorkspaceCredentialContract, { - params: { id: payload.credentialId }, - body: { - displayName: payload.displayName, - description: payload.description, - serviceAccountJson: payload.serviceAccountJson, - signingSecret: payload.signingSecret, - botToken: payload.botToken, - apiToken: payload.apiToken, - domain: payload.domain, - clientId: payload.clientId, - clientSecret: payload.clientSecret, - orgId: payload.orgId, - dataCenter: payload.dataCenter, - }, + params: { id: credentialId }, + body, }) }, onMutate: async (variables) => { diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts index 6a5c459e5fb..397304e9eac 100644 --- a/apps/sim/lib/api/contracts/credentials.ts +++ b/apps/sim/lib/api/contracts/credentials.ts @@ -135,6 +135,17 @@ export const createCredentialBodySchema = z orgId: z.string().trim().min(1).max(255).optional(), /** Optional provider region selector (Zoho Desk data center). */ dataCenter: z.string().trim().min(1).max(32).optional(), + /** + * Grant selector for providers offering more than one server-to-server + * flow (Salesforce: `client_credentials` | `jwt_bearer`). The descriptor's + * option list is the real allowlist — an unrecognized value resolves to the + * provider's default rather than failing, so this only bounds length. + */ + authMethod: z.string().trim().min(1).max(64).optional(), + /** PEM private key for key-based grants (Salesforce JWT bearer). */ + privateKey: z.string().trim().min(1).max(8192).optional(), + /** Run-as username for key-based grants (Salesforce JWT `sub`). */ + username: z.string().trim().min(1).max(255).optional(), }) .superRefine((data, ctx) => { if (data.type === 'oauth') { @@ -210,6 +221,9 @@ export const updateCredentialByIdBodySchema = z clientSecret: z.string().trim().min(1).max(1024).optional(), orgId: z.string().trim().min(1).max(255).optional(), dataCenter: z.string().trim().min(1).max(32).optional(), + authMethod: z.string().trim().min(1).max(64).optional(), + privateKey: z.string().trim().min(1).max(8192).optional(), + username: z.string().trim().min(1).max(255).optional(), }) .strict() .refine( @@ -224,7 +238,10 @@ export const updateCredentialByIdBodySchema = z data.clientId !== undefined || data.clientSecret !== undefined || data.orgId !== undefined || - data.dataCenter !== undefined, + data.dataCenter !== undefined || + data.authMethod !== undefined || + data.privateKey !== undefined || + data.username !== undefined, { message: 'At least one field must be provided', path: ['displayName'], diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 23810f6ef81..5717d00db96 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -97,6 +97,12 @@ import { validateSignupEmailMx } from '@/lib/messaging/email/validation.server' import { isEmailVerificationEffectivelyEnabled } from '@/lib/messaging/email/verification' import { scheduleLifecycleEmail } from '@/lib/messaging/lifecycle' import { getMicrosoftRefreshTokenExpiry, isMicrosoftProvider } from '@/lib/oauth/microsoft' +import { + isSalesforceLoginOrigin, + isSalesforceOAuthProviderId, + SALESFORCE_LOGIN_HOSTS, + withSalesforceInstanceScope, +} from '@/lib/oauth/salesforce' import { extractSlackTeamId, fanOutSlackTokenChain } from '@/lib/oauth/slack' import { clearDeadFlag } from '@/lib/oauth/terminal-errors' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' @@ -155,6 +161,41 @@ const trustedProxies = (env.AUTH_TRUSTED_PROXIES ?? '') .map((entry) => entry.trim()) .filter(Boolean) +/** + * Resolves the org's API instance URL for a freshly linked Salesforce account. + * + * The token response never carries `instance_url`, but `/services/oauth2/userinfo` + * returns a `profile` URL rooted at the org's own host. A response still rooted + * at the login host means userinfo answered for the authorization server rather + * than an org, which is not an instance URL — hence the guard. + * + * @returns The instance URL origin, or undefined when it cannot be determined + * (the caller then leaves `scope` untouched rather than storing a wrong host). + */ +async function fetchSalesforceInstanceUrl( + providerId: string, + accessToken: string +): Promise { + const loginHost = SALESFORCE_LOGIN_HOSTS[providerId] + if (!loginHost) return undefined + try { + const response = await fetch(`https://${loginHost}/services/oauth2/userinfo`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + if (!response.ok) return undefined + const data = await response.json() + if (typeof data.profile !== 'string') return undefined + const url = new URL(data.profile) + // The origin becomes a tool base URL that carries the bearer token, so the + // scheme is pinned rather than inherited from whatever userinfo returned. + if (url.protocol !== 'https:' || isSalesforceLoginOrigin(url.origin)) return undefined + return url.origin + } catch (error) { + logger.error('Failed to fetch Salesforce instance URL', { error, providerId }) + return undefined + } +} + export const auth = betterAuth({ baseURL: getBaseUrl(), // Where Better Auth sends OAuth callbacks that fail before the flow state is @@ -352,30 +393,13 @@ export const auth = betterAuth({ before: async (account) => { const modifiedAccount = { ...account } - if (account.providerId === 'salesforce' && account.accessToken) { - try { - const response = await fetch( - 'https://login.salesforce.com/services/oauth2/userinfo', - { - headers: { - Authorization: `Bearer ${account.accessToken}`, - }, - } - ) - - if (response.ok) { - const data = await response.json() - - if (data.profile) { - const match = data.profile.match(/^(https:\/\/[^/]+)/) - if (match && match[1] !== 'https://login.salesforce.com') { - const instanceUrl = match[1] - modifiedAccount.scope = `__sf_instance__:${instanceUrl} ${account.scope}` - } - } - } - } catch (error) { - logger.error('Failed to fetch Salesforce instance URL', { error }) + if (account.accessToken && isSalesforceOAuthProviderId(account.providerId)) { + const instanceUrl = await fetchSalesforceInstanceUrl( + account.providerId, + account.accessToken + ) + if (instanceUrl) { + modifiedAccount.scope = withSalesforceInstanceScope(instanceUrl, account.scope) } } @@ -548,7 +572,7 @@ export const auth = betterAuth({ ) } - if (account.providerId === 'salesforce') { + if (isSalesforceOAuthProviderId(account.providerId)) { const updates: { accessTokenExpiresAt?: Date scope?: string @@ -559,29 +583,12 @@ export const auth = betterAuth({ } if (account.accessToken) { - try { - const response = await fetch( - 'https://login.salesforce.com/services/oauth2/userinfo', - { - headers: { - Authorization: `Bearer ${account.accessToken}`, - }, - } - ) - - if (response.ok) { - const data = await response.json() - - if (data.profile) { - const match = data.profile.match(/^(https:\/\/[^/]+)/) - if (match && match[1] !== 'https://login.salesforce.com') { - const instanceUrl = match[1] - updates.scope = `__sf_instance__:${instanceUrl} ${account.scope}` - } - } - } - } catch (error) { - logger.error('Failed to fetch Salesforce instance URL', { error }) + const instanceUrl = await fetchSalesforceInstanceUrl( + account.providerId, + account.accessToken + ) + if (instanceUrl) { + updates.scope = withSalesforceInstanceScope(instanceUrl, account.scope) } } diff --git a/apps/sim/lib/auth/connectors/providers.ts b/apps/sim/lib/auth/connectors/providers.ts index 03ef86cee61..0119da1e4ac 100644 --- a/apps/sim/lib/auth/connectors/providers.ts +++ b/apps/sim/lib/auth/connectors/providers.ts @@ -13,6 +13,7 @@ import { } from '@/lib/core/utils/stream-limits' import { getBaseUrl } from '@/lib/core/utils/urls' import { getMicrosoftUserInfoFromIdToken } from '@/lib/oauth/microsoft' +import { SALESFORCE_LOGIN_HOSTS } from '@/lib/oauth/salesforce' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' import { deriveZohoDeskBaseFromApiDomain } from '@/tools/zoho_desk/host-allowlist' @@ -74,6 +75,61 @@ interface AttioWorkspaceMemberResponse { } } +/** + * Builds a Salesforce connector bound to one login host — `genericOAuth` takes + * static endpoints, so each authorization server needs its own registration. + * See {@link SALESFORCE_LOGIN_HOSTS} for why there are two. + */ +function salesforceConnector(providerId: string, loginHost: string): GenericOAuthConfig { + const userInfoUrl = `https://${loginHost}/services/oauth2/userinfo` + return { + providerId, + clientId: env.SALESFORCE_CLIENT_ID as string, + clientSecret: env.SALESFORCE_CLIENT_SECRET as string, + authorizationUrl: `https://${loginHost}/services/oauth2/authorize`, + tokenUrl: `https://${loginHost}/services/oauth2/token`, + userInfoUrl, + scopes: getCanonicalScopesForProvider('salesforce'), + pkce: true, + prompt: 'consent', + accessType: 'offline', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/${providerId}`, + getUserInfo: async (tokens) => { + try { + const response = await fetch(userInfoUrl, { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + await response.text().catch(() => {}) + logger.error('Failed to fetch Salesforce user info', { + status: response.status, + providerId, + }) + throw new Error('Failed to fetch user info') + } + + const data = await response.json() + + return { + id: `${(data.user_id || data.sub).toString()}-${generateId()}`, + name: data.name || 'Salesforce User', + email: data.email || syntheticConnectorEmail(providerId, data.user_id ?? data.sub), + emailVerified: data.email_verified === true, + image: data.picture || undefined, + createdAt: new Date(), + updatedAt: new Date(), + } + } catch (error) { + logger.error('Error creating Salesforce user profile:', { error, providerId }) + return null + } + }, + } +} + /** * Builds the connector list, evaluated once when `betterAuth()` constructs the * auth instance — the same point the array was built at when it was inline. @@ -935,51 +991,9 @@ export function buildConnectorProviders(): GenericOAuthConfig[] { }, }, - { - providerId: 'salesforce', - clientId: env.SALESFORCE_CLIENT_ID as string, - clientSecret: env.SALESFORCE_CLIENT_SECRET as string, - authorizationUrl: 'https://login.salesforce.com/services/oauth2/authorize', - tokenUrl: 'https://login.salesforce.com/services/oauth2/token', - userInfoUrl: 'https://login.salesforce.com/services/oauth2/userinfo', - scopes: getCanonicalScopesForProvider('salesforce'), - pkce: true, - prompt: 'consent', - accessType: 'offline', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/salesforce`, - getUserInfo: async (tokens) => { - try { - const response = await fetch('https://login.salesforce.com/services/oauth2/userinfo', { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - }) - - if (!response.ok) { - await response.text().catch(() => {}) - logger.error('Failed to fetch Salesforce user info', { - status: response.status, - }) - throw new Error('Failed to fetch user info') - } - - const data = await response.json() - - return { - id: `${(data.user_id || data.sub).toString()}-${generateId()}`, - name: data.name || 'Salesforce User', - email: data.email || syntheticConnectorEmail('salesforce', data.user_id ?? data.sub), - emailVerified: data.email_verified === true, - image: data.picture || undefined, - createdAt: new Date(), - updatedAt: new Date(), - } - } catch (error) { - logger.error('Error creating Salesforce user profile:', { error }) - return null - } - }, - }, + ...Object.entries(SALESFORCE_LOGIN_HOSTS).map(([providerId, loginHost]) => + salesforceConnector(providerId, loginHost) + ), { providerId: 'zoho-desk', diff --git a/apps/sim/lib/copilot/tools/server/user/get-credentials.test.ts b/apps/sim/lib/copilot/tools/server/user/get-credentials.test.ts index 133dd93704b..b1937a86e71 100644 --- a/apps/sim/lib/copilot/tools/server/user/get-credentials.test.ts +++ b/apps/sim/lib/copilot/tools/server/user/get-credentials.test.ts @@ -41,6 +41,28 @@ afterAll(resetEnvironmentUtilsMock) vi.mock('@/lib/oauth', () => ({ getAllOAuthServices: getAllOAuthServicesMock, + // Real implementation: folds only an alternate authorization server's id onto + // its service, never a family-wide service-account id. + canonicalizeServiceProviderId: ( + credentialProviderId: string, + service?: { providerId: string; additionalProviderIds?: readonly string[] } + ) => + service?.additionalProviderIds?.includes(credentialProviderId) + ? service.providerId + : credentialProviderId, + // Real implementation: the tool resolves a credential's provider id to its + // service through this, including alternate authorization servers. + credentialProviderMatchesService: ( + credentialProviderId: string, + service: { + providerId: string + serviceAccountProviderId?: string + additionalProviderIds?: readonly string[] + } + ) => + service.providerId === credentialProviderId || + service.serviceAccountProviderId === credentialProviderId || + (service.additionalProviderIds?.includes(credentialProviderId) ?? false), })) vi.mock('@/lib/integrations/availability.server', () => ({ @@ -251,6 +273,98 @@ describe('getCredentialsServerTool', () => { ).not.toContain('claude-platform') }) + it('does not list a service as not-connected when only an alternate provider is connected', async () => { + // A credential stored under an alternate authorization server + // (`salesforce-sandbox`) still connects the canonical service. Recording the + // raw id would list Salesforce as connected AND not connected at once. + getAllOAuthServicesMock.mockReturnValue([ + { + serviceId: 'salesforce', + providerId: 'salesforce', + additionalProviderIds: ['salesforce-sandbox'], + serviceAccountProviderId: 'salesforce-service-account', + name: 'Salesforce', + description: 'Salesforce CRM', + baseProvider: 'salesforce', + authType: 'oauth', + }, + ]) + // beforeEach already queued the default Google row; replace the queue so + // the sandbox account is the only credential this case sees. + resetDbChainMock() + wireDb( + [ + { + id: 'acct-sf-sandbox', + providerId: 'salesforce-sandbox', + accountId: 'sf-1', + idToken: null, + updatedAt: new Date('2026-04-17T02:26:05.546Z'), + }, + ], + [{ email: 'brent@cellular.so' }] + ) + + const result = await getCredentialsServerTool.execute({}, { userId: 'user-1' }) + + expect( + result.oauth.connected.credentials.map((c: { provider: string }) => c.provider) + ).toContain('salesforce-sandbox') + expect( + result.oauth.notConnected.services.map( + (service: { providerId: string }) => service.providerId + ) + ).not.toContain('salesforce') + }) + + it('does not drop a sibling service when a family-wide service account is connected', async () => { + // One `google-service-account` credential matches EVERY Google service via + // `serviceAccountProviderId`. Folding it onto the first match would remove + // exactly one arbitrary product from not-connected and leave the rest. + getAllOAuthServicesMock.mockReturnValue([ + { + serviceId: 'gmail', + providerId: 'google-email', + serviceAccountProviderId: 'google-service-account', + name: 'Gmail', + description: 'Gmail', + baseProvider: 'google', + authType: 'oauth', + }, + { + serviceId: 'google-drive', + providerId: 'google-drive', + serviceAccountProviderId: 'google-service-account', + name: 'Google Drive', + description: 'Drive', + baseProvider: 'google', + authType: 'oauth', + }, + ]) + resetDbChainMock() + wireDb([], [{ email: 'brent@cellular.so' }]) + getAccessibleOAuthCredentialsMock.mockResolvedValue([ + { + id: 'google-sa-1', + providerId: 'google-service-account', + type: 'service_account', + displayName: 'Google SA', + updatedAt: new Date('2026-04-17T02:26:05.546Z'), + }, + ]) + + const result = await getCredentialsServerTool.execute( + {}, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + // Either both stay listed or neither does — never one arbitrary sibling. + const notConnected = result.oauth.notConnected.services.map( + (service: { providerId: string }) => service.providerId + ) + expect(notConnected).toEqual(expect.arrayContaining(['google-email', 'google-drive'])) + }) + it('hides shared service-account credentials disallowed for the viewer', async () => { getUserPermissionConfigMock.mockResolvedValue({ allowedIntegrations: ['slack'] }) getAccessibleOAuthCredentialsMock.mockResolvedValue([ diff --git a/apps/sim/lib/copilot/tools/server/user/get-credentials.ts b/apps/sim/lib/copilot/tools/server/user/get-credentials.ts index 9e6e1076e79..b96466a1b37 100644 --- a/apps/sim/lib/copilot/tools/server/user/get-credentials.ts +++ b/apps/sim/lib/copilot/tools/server/user/get-credentials.ts @@ -10,7 +10,11 @@ import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { getAccessibleOAuthCredentials } from '@/lib/credentials/environment' import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' import { createIntegrationCredentialVisibility } from '@/lib/integrations/credential-visibility.server' -import { getAllOAuthServices } from '@/lib/oauth' +import { + canonicalizeServiceProviderId, + credentialProviderMatchesService, + getAllOAuthServices, +} from '@/lib/oauth' import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' import { checkWorkspaceAccess, type WorkspaceAccess } from '@/lib/workspaces/permissions/utils' import { overlayVisibility } from '@/blocks/visibility/context' @@ -106,9 +110,14 @@ export const getCredentialsServerTool: BaseServerTool for (const acc of accounts) { const providerId = acc.providerId - const service = allOAuthServices.find((candidate) => candidate.providerId === providerId) + const service = allOAuthServices.find((candidate) => + credentialProviderMatchesService(providerId, candidate) + ) if (!credentialVisibility.isCredentialVisible({ providerId, type: 'oauth' })) continue - connectedProviderIds.add(providerId) + // `notConnectedServices` below compares against `service.providerId`, so an + // alternate authorization server's id (`salesforce-sandbox`) has to fold + // onto it or the service is listed as connected AND not connected. + connectedProviderIds.add(canonicalizeServiceProviderId(providerId, service)) const [baseProvider, featureType = 'default'] = providerId.split('-') let displayName = '' @@ -158,12 +167,10 @@ export const getCredentialsServerTool: BaseServerTool ) { continue } - const service = allOAuthServices.find( - (candidate) => - candidate.providerId === cred.providerId || - candidate.serviceAccountProviderId === cred.providerId + const service = allOAuthServices.find((candidate) => + credentialProviderMatchesService(cred.providerId, candidate) ) - connectedProviderIds.add(cred.providerId) + connectedProviderIds.add(canonicalizeServiceProviderId(cred.providerId, service)) const [, featureType = 'default'] = cred.providerId.split('-') connectedCredentials.push({ id: cred.id, diff --git a/apps/sim/lib/core/config/env-capabilities.ts b/apps/sim/lib/core/config/env-capabilities.ts index db789625e9a..830838fecf3 100644 --- a/apps/sim/lib/core/config/env-capabilities.ts +++ b/apps/sim/lib/core/config/env-capabilities.ts @@ -1395,6 +1395,9 @@ export function resolveOAuthClientCapabilityId(serviceId: string): OAuthClientCa if (GOOGLE_OAUTH_SERVICES.has(normalized)) return 'google' if (MICROSOFT_OAUTH_SERVICES.has(normalized)) return 'microsoft' if (normalized === 'zoho') return 'zoho-desk' + // One consumer key serves both Salesforce login hosts, so the sandbox provider + // is configured by the same env pair — without this alias it is silently dropped. + if (normalized === 'salesforce-sandbox') return 'salesforce' return normalized in OAUTH_CLIENT_CAPABILITIES ? (normalized as OAuthClientCapabilityId) : null } diff --git a/apps/sim/lib/credentials/client-credential-accounts/descriptors.test.ts b/apps/sim/lib/credentials/client-credential-accounts/descriptors.test.ts new file mode 100644 index 00000000000..1aefdb338c1 --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/descriptors.test.ts @@ -0,0 +1,104 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + BOX_SERVICE_ACCOUNT_PROVIDER_ID, + getClientCredentialAccountDescriptor, + partitionClientCredentialFields, + resolveClientCredentialAuthMethod, + resolveSalesforceAuthMethod, + SALESFORCE_DEFAULT_AUTH_METHOD, + SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID, + ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID, +} from '@/lib/credentials/client-credential-accounts/descriptors' + +const salesforce = getClientCredentialAccountDescriptor(SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID)! +const box = getClientCredentialAccountDescriptor(BOX_SERVICE_ACCOUNT_PROVIDER_ID)! +const zohoDesk = getClientCredentialAccountDescriptor(ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID)! + +const ids = (fields: { id: string }[]) => fields.map((field) => field.id) + +describe('partitionClientCredentialFields', () => { + describe('single-grant providers are unaffected by the auth-method machinery', () => { + it('keeps every Box field visible and required', () => { + const { visible, required } = partitionClientCredentialFields(box, undefined) + expect(ids(visible)).toEqual(['clientId', 'clientSecret', 'orgId']) + expect(ids(required)).toEqual(['clientId', 'clientSecret', 'orgId']) + }) + + it("keeps Zoho Desk's optional data center visible but not required", () => { + const { visible, required } = partitionClientCredentialFields(zohoDesk, undefined) + expect(ids(visible)).toContain('dataCenter') + expect(ids(required)).not.toContain('dataCenter') + expect(ids(required)).toEqual(['clientId', 'clientSecret', 'orgId']) + }) + + it('ignores an auth method a single-grant provider does not declare', () => { + const { required } = partitionClientCredentialFields(box, 'jwt_bearer') + expect(ids(required)).toEqual(['clientId', 'clientSecret', 'orgId']) + }) + }) + + describe('Salesforce, which offers two grants', () => { + it('requires the consumer secret and hides key material on the client-credentials branch', () => { + const { visible, required } = partitionClientCredentialFields( + salesforce, + 'client_credentials' + ) + expect(ids(required)).toEqual(['clientId', 'clientSecret', 'orgId']) + expect(ids(visible)).not.toContain('privateKey') + expect(ids(visible)).not.toContain('username') + }) + + it('requires the key and username on the JWT branch, and hides the consumer secret', () => { + const { visible, required } = partitionClientCredentialFields(salesforce, 'jwt_bearer') + expect(ids(required)).toEqual(['clientId', 'privateKey', 'username', 'orgId']) + expect(ids(visible)).not.toContain('clientSecret') + }) + + it.each([ + ['absent', undefined], + ['empty', ''], + ['unrecognized', 'totally-made-up'], + ])('falls back to the default grant when the method is %s', (_label, authMethod) => { + // Credentials created before the JWT branch existed carry no `authMethod`, + // so the fallback is what keeps them minting as they always did. + const { required } = partitionClientCredentialFields(salesforce, authMethod) + expect(ids(required)).toContain('clientSecret') + expect(ids(required)).not.toContain('privateKey') + }) + + it('never marks the method selector itself required', () => { + const { visible, required } = partitionClientCredentialFields(salesforce, 'jwt_bearer') + expect(ids(visible)).toContain('authMethod') + expect(ids(required)).not.toContain('authMethod') + }) + }) +}) + +describe('resolveClientCredentialAuthMethod', () => { + it('returns undefined for a provider that declares no method selector', () => { + expect(resolveClientCredentialAuthMethod(box, 'jwt_bearer')).toBeUndefined() + }) + + it('accepts a declared method and rejects anything else', () => { + expect(resolveClientCredentialAuthMethod(salesforce, 'jwt_bearer')).toBe('jwt_bearer') + expect(resolveClientCredentialAuthMethod(salesforce, ' jwt_bearer ')).toBe('jwt_bearer') + expect(resolveClientCredentialAuthMethod(salesforce, 'nope')).toBe( + SALESFORCE_DEFAULT_AUTH_METHOD + ) + }) +}) + +describe('resolveSalesforceAuthMethod', () => { + it('agrees with the descriptor-driven resolver the create path uses', () => { + // The minter holds only the raw stored value; if these two ever disagreed a + // credential would validate under one grant and mint under the other. + for (const value of [undefined, '', 'client_credentials', 'jwt_bearer', 'garbage']) { + expect(resolveSalesforceAuthMethod(value)).toBe( + resolveClientCredentialAuthMethod(salesforce, value) + ) + } + }) +}) diff --git a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts index 0996bdf3e75..78bbd0af247 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts @@ -15,7 +15,21 @@ export const CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE = 'client_credential_account' as const /** Contract field ids a client-credential connect modal collects. */ -export type ClientCredentialAccountFieldId = 'clientId' | 'clientSecret' | 'orgId' | 'dataCenter' +export type ClientCredentialAccountFieldId = + | 'clientId' + | 'clientSecret' + | 'orgId' + | 'dataCenter' + | 'authMethod' + | 'privateKey' + | 'username' + +/** + * The field id that selects between a descriptor's auth methods. A descriptor + * declaring it must also set `defaultAuthMethod`, or every branch-specific + * field resolves to hidden. + */ +export const AUTH_METHOD_FIELD_ID = 'authMethod' as const satisfies ClientCredentialAccountFieldId export interface ClientCredentialAccountOption { value: string @@ -28,6 +42,22 @@ export interface ClientCredentialAccountField { placeholder: string /** Rendered with SecretInput and never echoed back. */ secret: boolean + /** + * Renders a multi-line control instead of a single-line one. Required for + * PEM-encoded material (a private key spans ~28 newline-separated lines and + * is unreadable — and unverifiable by eye — in a single-line input). A + * `secret` field that is also `multiline` renders as a plain textarea rather + * than a masked input; the modal never prefills a secret, so masking would + * only hide the user's own paste from them. + */ + multiline?: boolean + /** + * Auth methods this field belongs to, for descriptors offering more than one + * (Salesforce: client credentials vs JWT bearer). The field is hidden, and + * skipped by validation, unless the selected method appears in this list. + * Absent means the field belongs to every branch. + */ + requiredForAuthMethods?: readonly string[] /** * Field the connect modal may submit empty; excluded from * {@link CLIENT_CREDENTIAL_ACCOUNT_REQUIRED_FIELDS} so create/reconnect @@ -65,6 +95,11 @@ export interface ClientCredentialAccountDescriptor { */ connectNoun: string fields: ClientCredentialAccountField[] + /** + * Grant used when no `authMethod` is submitted or stored. Required on any + * descriptor carrying an `authMethod` field; meaningless without one. + */ + defaultAuthMethod?: string /** Sim setup guide, docked bottom-left of the connect modal. */ docsUrl: string /** Optional one-line caveat rendered in the connect modal. */ @@ -93,6 +128,37 @@ export type ClientCredentialAccountProviderId = export const SALESFORCE_MY_DOMAIN_HOST_REGEX = /^[a-z0-9][a-z0-9-]*(--[a-z0-9]+)?(\.(sandbox|develop|scratch|demo|patch|trailblaze|free))?\.my\.salesforce\.com$/ +/** + * Server-to-server grants a Salesforce integration app can authenticate with. + * `client_credentials` posts a consumer key + secret; `jwt_bearer` posts an + * RS256-signed assertion and names the user to run as, so it needs no shared + * secret at all. This list is the only allowlist — every consumer resolves + * against it through {@link resolveSalesforceAuthMethod}. + * @see https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_jwt_flow.htm&type=5 + */ +const SALESFORCE_AUTH_METHOD_OPTIONS: ReadonlyArray = [ + { value: 'client_credentials', label: 'Client credentials (consumer secret)' }, + { value: 'jwt_bearer', label: 'JWT bearer (private key)' }, +] + +/** + * Client-credentials stays the default so credentials created before the JWT + * branch existed — whose stored blob carries no `authMethod` — keep minting + * exactly as they did. + */ +export const SALESFORCE_DEFAULT_AUTH_METHOD = 'client_credentials' + +/** A Salesforce username is an email-shaped login, not necessarily a real mailbox. */ +export const SALESFORCE_USERNAME_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +/** + * A PEM private key header, in either container `crypto.createPrivateKey` + * accepts. Catches the common mix-up of pasting `server.crt` (the certificate + * that belongs in Salesforce) instead of `server.key`, which would otherwise + * only surface as an opaque failed mint. + */ +export const SALESFORCE_PRIVATE_KEY_REGEX = /-----BEGIN (RSA )?PRIVATE KEY-----/ + /** * Normalizes a pasted My Domain value to a bare host: strips the protocol, * any path/query/fragment, and trailing content, then lowercases. Shared by @@ -283,6 +349,14 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record< serviceLabel: 'Salesforce', connectNoun: 'integration user app', fields: [ + { + id: 'authMethod', + label: 'Authentication method', + placeholder: 'Select a method', + secret: false, + optional: true, + options: SALESFORCE_AUTH_METHOD_OPTIONS, + }, { id: 'clientId', label: 'Consumer key', @@ -294,6 +368,30 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record< label: 'Consumer secret', placeholder: 'Paste the consumer secret', secret: true, + optional: true, + requiredForAuthMethods: ['client_credentials'], + }, + { + id: 'privateKey', + label: 'Private key', + placeholder: '-----BEGIN PRIVATE KEY-----', + secret: true, + multiline: true, + optional: true, + requiredForAuthMethods: ['jwt_bearer'], + hintPattern: SALESFORCE_PRIVATE_KEY_REGEX, + hintMessage: 'Expected a PEM private key (server.key), not the certificate.', + hint: 'Must match the certificate uploaded to the app.', + }, + { + id: 'username', + label: 'Run as username', + placeholder: 'integration.user@yourorg.com', + secret: false, + optional: true, + requiredForAuthMethods: ['jwt_bearer'], + hintPattern: SALESFORCE_USERNAME_REGEX, + hintMessage: 'Expected a Salesforce username, which is email-shaped (user@example.com).', }, { id: 'orgId', @@ -303,12 +401,13 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record< hintPattern: SALESFORCE_MY_DOMAIN_HOST_REGEX, hintNormalize: normalizeSalesforceMyDomainHost, hintMessage: - 'Expected a My Domain host like yourorg.my.salesforce.com, yourorg--sbx.sandbox.my.salesforce.com, or yourorg-dev-ed.develop.my.salesforce.com.', + 'Expected a My Domain host like yourorg.my.salesforce.com, yourorg--sbx.sandbox.my.salesforce.com, or yourorg-dev-ed.develop.my.salesforce.com — not the my.salesforce-setup.com or lightning.force.com host.', }, ], + defaultAuthMethod: SALESFORCE_DEFAULT_AUTH_METHOD, docsUrl: 'https://docs.sim.ai/integrations/salesforce-service-account', helpText: - 'Every call executes as the Connected App\'s "Run As" user, so deactivating or freezing that user stops all runs. Without the "openid" scope the connection still works, but Sim cannot record which user it authenticates as.', + 'Every call runs as one integration user, so deactivating or freezing that user stops all runs. Without the "openid" scope the connection still works, but Sim cannot record which user it authenticates as.', }, [ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID, @@ -370,6 +469,73 @@ export const CLIENT_CREDENTIAL_ACCOUNT_REQUIRED_FIELDS: Record< ]) ) +/** + * Resolves a submitted or stored `authMethod` to one the descriptor actually + * offers, falling back to its `defaultAuthMethod`. Returns `undefined` for + * single-grant providers, whose fields are never method-conditional. + * + * Resolving (rather than reading the raw value) is what makes credentials + * created before the field existed keep working: their blob carries no + * `authMethod`, and the default is the grant they were created with. + */ +export function resolveClientCredentialAuthMethod( + descriptor: ClientCredentialAccountDescriptor, + authMethod: string | undefined +): string | undefined { + const options = descriptor.fields.find((field) => field.id === AUTH_METHOD_FIELD_ID)?.options + if (!options) return undefined + const trimmed = authMethod?.trim() + return options.some((option) => option.value === trimmed) ? trimmed : descriptor.defaultAuthMethod +} + +/** + * Splits a descriptor's fields for one auth method: `visible` is what the + * connect modal renders, `required` is what must be non-empty to submit. + * + * A field with no `requiredForAuthMethods` belongs to every branch and is + * required unless marked `optional`; a branch-specific field is both hidden + * and skipped by validation on the other branches. Resolves the method once, + * so callers never re-resolve per field. + * + * The static {@link CLIENT_CREDENTIAL_ACCOUNT_REQUIRED_FIELDS} map above + * cannot express this — it feeds a contract schema that validates one shape + * per provider — so the branch-specific requirement is enforced by the + * server-side secret builder and mirrored by the connect modal's submit gate. + */ +export function partitionClientCredentialFields( + descriptor: ClientCredentialAccountDescriptor, + authMethod: string | undefined +): { visible: ClientCredentialAccountField[]; required: ClientCredentialAccountField[] } { + const resolved = resolveClientCredentialAuthMethod(descriptor, authMethod) + const visible: ClientCredentialAccountField[] = [] + const required: ClientCredentialAccountField[] = [] + for (const field of descriptor.fields) { + if (field.requiredForAuthMethods) { + if (resolved === undefined || !field.requiredForAuthMethods.includes(resolved)) continue + visible.push(field) + required.push(field) + continue + } + visible.push(field) + if (!field.optional) required.push(field) + } + return { visible, required } +} + +/** + * Resolves an auth method against the Salesforce descriptor's own option list, + * for the minter — which holds only the raw stored value and must agree with + * the validation that gated the credential at create time. + */ +export function resolveSalesforceAuthMethod(authMethod: string | undefined): string { + return ( + resolveClientCredentialAuthMethod( + CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS[SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID], + authMethod + ) ?? SALESFORCE_DEFAULT_AUTH_METHOD + ) +} + export function isClientCredentialAccountProviderId( value: string | null | undefined ): value is ClientCredentialAccountProviderId { diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/box.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/box.ts index 25e3b609080..58a17d48fc3 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/box.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/box.ts @@ -12,6 +12,7 @@ import { parseProviderJson, providerFailureReason, readProviderErrorSnippet, + requireClientSecret, TokenServiceAccountValidationError, } from '@/lib/credentials/token-service-accounts/errors' @@ -148,6 +149,7 @@ export async function mintBoxServiceAccountToken( fields: ClientCredentialAccountFields, options?: ClientCredentialAccountMintOptions ): Promise { + const clientSecret = requireClientSecret(fields.clientSecret, 'box_token_mint', 'Box') const res = await fetchProvider( BOX_TOKEN_URL, { @@ -156,7 +158,7 @@ export async function mintBoxServiceAccountToken( body: new URLSearchParams({ grant_type: 'client_credentials', client_id: fields.clientId, - client_secret: fields.clientSecret, + client_secret: clientSecret, box_subject_type: 'enterprise', box_subject_id: fields.orgId, }).toString(), diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.test.ts index 0b874321280..01bc6f68131 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { createVerify, generateKeyPairSync } from 'crypto' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mintSalesforceServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/salesforce' @@ -341,3 +342,246 @@ describe('mintSalesforceServiceAccountToken', () => { expect(result.expiresInSeconds).toBe(600) }) }) + +describe('mintSalesforceServiceAccountToken (JWT bearer)', () => { + /** + * A real 2048-bit RSA keypair, generated once per run. Signing against a + * genuine key is the point: it is the only way to prove the assertion + * verifies with `RS256` and that both PEM containers load. + */ + const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }) + const PKCS8_PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString() + const PKCS1_PEM = privateKey.export({ type: 'pkcs1', format: 'pem' }).toString() + + const JWT_FIELDS = { + clientId: 'test-consumer-key', + orgId: HOST, + authMethod: 'jwt_bearer', + username: 'integration.user@yourorg.com', + privateKey: PKCS8_PEM, + } + + /** Pulls the posted assertion apart and verifies its RS256 signature. */ + function readPostedAssertion(): { + header: { alg: string; typ: string } + claims: { aud: string; iss: string; sub: string; exp: number; iat: number } + verified: boolean + } { + const [url, init] = mockFetch.mock.calls[0] + expect(url).toBe(TOKEN_URL) + const body = new URLSearchParams(init.body as string) + expect(body.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:jwt-bearer') + expect(body.get('client_secret')).toBeNull() + const assertion = body.get('assertion') as string + const [header, claims, signature] = assertion.split('.') + return { + header: JSON.parse(Buffer.from(header, 'base64url').toString()), + claims: JSON.parse(Buffer.from(claims, 'base64url').toString()), + verified: createVerify('RSA-SHA256') + .update(`${header}.${claims}`) + .end() + .verify(publicKey, Buffer.from(signature, 'base64url')), + } + } + + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('posts an RS256 assertion whose signature verifies against the public key', async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse(200, { access_token: 'sf-jwt-token', instance_url: INSTANCE_URL }) + ) + .mockResolvedValueOnce(jsonResponse(403, {})) + + await mintSalesforceServiceAccountToken(JWT_FIELDS) + + const { header, verified } = readPostedAssertion() + expect(header).toEqual({ alg: 'RS256', typ: 'JWT' }) + expect(verified).toBe(true) + }) + + it('audiences the assertion at the My Domain host, never login/test.salesforce.com', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(200, { access_token: 'sf-jwt-token' })) + .mockResolvedValueOnce(jsonResponse(403, {})) + + await mintSalesforceServiceAccountToken(JWT_FIELDS) + + const { claims } = readPostedAssertion() + // Salesforce Spring '26 ended legacy hostname redirections; an External + // Client App rejects the generic hosts with `app_not_found`. + expect(claims.aud).toBe(`https://${HOST}`) + expect(claims.iss).toBe('test-consumer-key') + expect(claims.sub).toBe('integration.user@yourorg.com') + }) + + it('audiences a Government Cloud org at gs1.salesforce.com, not its My Domain', async () => { + // Salesforce's own sfdx-core substitutes this audience for gs1 orgs, whose + // hosts are otherwise ordinary *.my.salesforce.com. + mockFetch + .mockResolvedValueOnce(jsonResponse(200, { access_token: 'sf-jwt-token' })) + .mockResolvedValueOnce(jsonResponse(403, {})) + + await mintSalesforceServiceAccountToken({ ...JWT_FIELDS, orgId: 'gs1.my.salesforce.com' }) + + const [url, init] = mockFetch.mock.calls[0] + const assertion = new URLSearchParams(init.body as string).get('assertion') as string + const claims = JSON.parse(Buffer.from(assertion.split('.')[1], 'base64url').toString()) + expect(claims.aud).toBe('https://gs1.salesforce.com') + // The token still POSTs to the org's own host — only the audience differs. + expect(url).toBe('https://gs1.my.salesforce.com/services/oauth2/token') + }) + + it('does NOT treat an ordinary org whose name merely starts with gs1 as Government Cloud', async () => { + // A prefix test would misroute this org to the GovCloud audience and break + // a setup that works today. Only the real GovCloud host may be rewritten. + mockFetch + .mockResolvedValueOnce(jsonResponse(200, { access_token: 'sf-jwt-token' })) + .mockResolvedValueOnce(jsonResponse(403, {})) + + await mintSalesforceServiceAccountToken({ + ...JWT_FIELDS, + orgId: 'gs1-widgets.my.salesforce.com', + }) + + const assertion = new URLSearchParams(mockFetch.mock.calls[0][1].body as string).get( + 'assertion' + ) as string + const claims = JSON.parse(Buffer.from(assertion.split('.')[1], 'base64url').toString()) + expect(claims.aud).toBe('https://gs1-widgets.my.salesforce.com') + }) + + it('carries an iat claim, matching every mainstream Salesforce implementation', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(200, { access_token: 'sf-jwt-token' })) + .mockResolvedValueOnce(jsonResponse(403, {})) + + await mintSalesforceServiceAccountToken(JWT_FIELDS) + + const { claims } = readPostedAssertion() + expect(claims.iat).toBeLessThanOrEqual(Math.floor(Date.now() / 1000)) + expect(claims.exp).toBeGreaterThan(claims.iat) + }) + + it('maps an unassigned profile to a permission-set hint', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(400, { + error: 'invalid_app_access', + error_description: 'user is not admin approved to access this app', + }) + ) + + await expect(mintSalesforceServiceAccountToken(JWT_FIELDS)).rejects.toMatchObject({ + code: 'invalid_credentials', + logDetail: { hint: expect.stringContaining('profile or permission set is not assigned') }, + }) + }) + + it('sets a short expiry inside the 5-minute window Salesforce allows', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(200, { access_token: 'sf-jwt-token' })) + .mockResolvedValueOnce(jsonResponse(403, {})) + + await mintSalesforceServiceAccountToken(JWT_FIELDS) + + const { claims } = readPostedAssertion() + const secondsAhead = claims.exp - Math.floor(Date.now() / 1000) + // Salesforce rejects an exp more than 5 minutes out; 180s is the value the + // minter uses, so the band is tight enough to catch drift in either direction. + expect(secondsAhead).toBeGreaterThan(150) + expect(secondsAhead).toBeLessThanOrEqual(180) + }) + + it('accepts a PKCS#1 key, which is what OpenSSL 1.x emits', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(200, { access_token: 'sf-jwt-token' })) + .mockResolvedValueOnce(jsonResponse(403, {})) + + await mintSalesforceServiceAccountToken({ ...JWT_FIELDS, privateKey: PKCS1_PEM }) + + expect(readPostedAssertion().verified).toBe(true) + }) + + it('rejects a passphrase-protected key without calling Salesforce', async () => { + const encrypted = privateKey + .export({ + type: 'pkcs8', + format: 'pem', + cipher: 'aes-256-cbc', + passphrase: 'hunter2', + }) + .toString() + + await expect( + mintSalesforceServiceAccountToken({ ...JWT_FIELDS, privateKey: encrypted }) + ).rejects.toMatchObject({ + code: 'invalid_credentials', + // The actionable remediation is the whole point of the branch; asserting + // only the code lets the guard be deleted with the test still green. + logDetail: { reason: expect.stringContaining('passphrase-protected') }, + }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('rejects an unreadable key without calling Salesforce', async () => { + await expect( + mintSalesforceServiceAccountToken({ ...JWT_FIELDS, privateKey: 'not-a-pem' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('rejects a JWT credential missing its username without calling Salesforce', async () => { + await expect( + mintSalesforceServiceAccountToken({ ...JWT_FIELDS, username: undefined }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('still uses client credentials when no auth method is stored', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(200, { access_token: 'sf-token' })) + .mockResolvedValueOnce(jsonResponse(403, {})) + + await mintSalesforceServiceAccountToken(FIELDS) + + const body = new URLSearchParams(mockFetch.mock.calls[0][1].body as string) + expect(body.get('grant_type')).toBe('client_credentials') + }) + + it('maps app_not_found to a hint naming the org/consumer-key mismatch', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(400, { + error: 'app_not_found', + error_description: 'External client app is not installed in this org', + }) + ) + + await expect(mintSalesforceServiceAccountToken(JWT_FIELDS)).rejects.toMatchObject({ + code: 'invalid_credentials', + logDetail: { hint: expect.stringContaining('not installed in this org') }, + }) + }) + + it.each([ + ['straight apostrophe', "user hasn't approved this consumer"], + // Salesforce's real error text uses a typographic apostrophe. Covering only + // the straight form let that branch be deleted without failing CI. + ['typographic apostrophe', 'user hasn\u2019t approved this consumer'], + ])('maps an unapproved run-as user (%s) to a pre-authorization hint', async (_l, description) => { + mockFetch.mockResolvedValueOnce( + jsonResponse(400, { error: 'invalid_grant', error_description: description }) + ) + + await expect(mintSalesforceServiceAccountToken(JWT_FIELDS)).rejects.toMatchObject({ + code: 'invalid_credentials', + logDetail: { hint: expect.stringContaining('Admin approved users are pre-authorized') }, + }) + }) +}) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts index e8e702c98c7..495dbb48310 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts @@ -1,7 +1,10 @@ +import { createPrivateKey, type KeyObject } from 'crypto' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { SignJWT } from 'jose' import { normalizeSalesforceMyDomainHost, + resolveSalesforceAuthMethod, SALESFORCE_MY_DOMAIN_HOST_REGEX, } from '@/lib/credentials/client-credential-accounts/descriptors' import type { @@ -30,6 +33,18 @@ const SALESFORCE_TOKEN_TTL_SECONDS = 600 const IDENTITY_STEP = 'salesforce_identity' +const TOKEN_MINT_STEP = 'salesforce_token_mint' + +/** + * Lifetime of the signed assertion, not of the access token it buys. Salesforce + * documents a 5-minute ceiling; a short window bounds replay while leaving room + * for clock skew. The binding constraint is the lower bound — a Sim clock more + * than this far behind Salesforce's fails every mint. + */ +const JWT_ASSERTION_LIFETIME_SECONDS = 180 + +const JWT_BEARER_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer' + const logger = createLogger('SalesforceServiceAccountMinter') interface SalesforceTokenResponse { @@ -189,22 +204,222 @@ async function fetchSalesforceIdentity( } /** - * Mints a Salesforce access token via the OAuth 2.0 Client Credentials Flow - * against the org's own My Domain token endpoint - * (`https://{host}/services/oauth2/token` — login.salesforce.com hard-rejects - * this grant). Credentials ride in the form body (client_secret_post) with no - * scope parameter (Salesforce doesn't support scopes on this endpoint; grants - * come from the Connected App config). The host is SSRF-guarded against the - * My Domain allowlist before any outbound fetch. + * Loads a pasted PEM private key. Accepts both PKCS#8 + * (`-----BEGIN PRIVATE KEY-----`, what OpenSSL 3 emits) and PKCS#1 + * (`-----BEGIN RSA PRIVATE KEY-----`, what OpenSSL 1.x emits) because + * Salesforce's own `openssl` recipe produces whichever the operator's OpenSSL + * defaults to. An encrypted key is rejected explicitly rather than surfacing + * OpenSSL's opaque "bad decrypt" — Sim collects no passphrase to unlock one. + */ +function loadSalesforcePrivateKey(privateKeyPem: string): KeyObject { + if (/ENCRYPTED PRIVATE KEY/.test(privateKeyPem)) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: 'jwt_key_load', + reason: + 'private key is passphrase-protected — decrypt it first (openssl rsa -in server.pass.key -out server.key)', + }) + } + try { + const key = createPrivateKey(privateKeyPem) + if (key.asymmetricKeyType !== 'rsa') { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: 'jwt_key_load', + reason: `Salesforce requires an RSA key for RS256, received ${key.asymmetricKeyType ?? 'an unrecognized key type'}`, + }) + } + // Signing is synchronous OpenSSL work on the libuv threadpool, and cost + // grows sharply with modulus size. Salesforce's own recipe is 2048-bit; + // anything past 4096 is a way to burn threadpool slots, not a real key. + const modulusLength = key.asymmetricKeyDetails?.modulusLength ?? 0 + if (modulusLength > 4096) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: 'jwt_key_load', + reason: `RSA key is ${modulusLength}-bit; 4096 is the maximum accepted`, + }) + } + return key + } catch (error) { + if (error instanceof TokenServiceAccountValidationError) throw error + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: 'jwt_key_load', + reason: 'private key is not a readable PEM key', + }) + } +} + +/** + * Builds the RS256-signed assertion Salesforce exchanges for an access token. + * + * `aud` is the org's own My Domain URL rather than `login.salesforce.com` / + * `test.salesforce.com`. Salesforce ended legacy hostname redirections in + * Spring '26, and External Client Apps now reject the generic sandbox host + * with `app_not_found` because it cannot identify which org the app is + * installed in. My Domain is valid for Connected Apps and External Client Apps + * alike, in production and in sandboxes, and is what Salesforce's own CLI + * recommends — so the stored host alone determines the environment, with + * nothing left to infer. + * + * Government Cloud is the documented exception: `sfdx-core` substitutes + * `https://gs1.salesforce.com` for `gs1` orgs, whose hosts are otherwise + * ordinary `*.my.salesforce.com`. + * + * @see https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_jwt_flow.htm&type=5 + * @see https://github.com/forcedotcom/sfdx-core/blob/main/src/util/sfdcUrl.ts + */ +function buildSalesforceJwtAssertion( + consumerKey: string, + username: string, + host: string, + privateKey: KeyObject +): Promise { + return ( + new SignJWT() + .setProtectedHeader({ alg: 'RS256', typ: 'JWT' }) + .setIssuer(consumerKey) + .setSubject(username) + .setAudience(salesforceJwtAudience(host)) + // Optional per RFC 7523, but every mainstream Salesforce implementation + // (including `sfdx-core`) sends it; costs nothing to match them. + .setIssuedAt() + .setExpirationTime(Math.floor(Date.now() / 1000) + JWT_ASSERTION_LIFETIME_SECONDS) + .sign(privateKey) + ) +} + +/** + * Government Cloud orgs authenticate at a dedicated audience; everyone else + * uses My Domain. + * + * Matched on the exact GovCloud host (or a subdomain of it) rather than a + * `gs1` prefix: `sfdx-core`'s other GovCloud signal is the org's + * `createdOrgInstance`, which we never see, and a prefix test would misroute + * an ordinary org that merely starts with those characters — breaking a setup + * that works today. A miss here simply falls back to My Domain, which is the + * behaviour before this branch existed. + */ +const SALESFORCE_GOV_CLOUD_HOST = 'gs1.my.salesforce.com' + +function salesforceJwtAudience(host: string): string { + const isGovCloud = + host === SALESFORCE_GOV_CLOUD_HOST || host.endsWith(`.${SALESFORCE_GOV_CLOUD_HOST}`) + return isGovCloud ? 'https://gs1.salesforce.com' : `https://${host}` +} + +/** + * Maps a JWT-bearer token error to an operator-facing hint. Salesforce + * collapses every JWT failure into HTTP 400 `invalid_grant`, distinguishing + * them only by `error_description`, so the description is the sole signal for + * which half of the setup is wrong. + * + * Substring matching is safe here precisely because the result only ever + * decorates a log line: the thrown code is `invalid_credentials` either way, + * so an unrecognized wording degrades to "no hint" and never changes + * behaviour. Prefer the structured `error` field wherever Salesforce sets one. + */ +function salesforceJwtErrorHint(body: string): string | undefined { + try { + const parsed = JSON.parse(body) as { error?: string; error_description?: string } + const description = (parsed.error_description ?? '').toLowerCase() + if (parsed.error === 'app_not_found') { + return 'the app is not installed in this org — check the My Domain host and that the consumer key belongs to that org' + } + if (parsed.error === 'invalid_app_access' || description.includes('admin approved')) { + return "the run-as user's profile or permission set is not assigned to the app — assign it under the app's OAuth policies" + } + if (description.includes('user hasn’t approved') || description.includes("hasn't approved")) { + return 'the run-as user has not approved the app — set its OAuth policy to "Admin approved users are pre-authorized" and assign the user a permitted profile or permission set' + } + if (description.includes('audience')) { + return 'the app rejected the audience — confirm the My Domain host matches the org the app is installed in' + } + if (description.includes('invalid assertion') || description.includes('invalid signature')) { + return 'the assertion signature did not verify — the uploaded certificate does not match this private key' + } + if (description.includes('client identifier') || parsed.error === 'invalid_client_id') { + return 'the consumer key is invalid for this org' + } + return undefined + } catch { + return undefined + } +} + +/** + * Builds the token-request form body for the selected grant, and the hint + * mapper that reads the failures that grant can produce. + * + * Client credentials posts the consumer key + secret directly + * (client_secret_post) with no scope parameter — Salesforce doesn't support + * scopes on this endpoint; grants come from the app config. JWT bearer posts a + * signed assertion instead, and carries no client secret at all. + */ +async function buildSalesforceTokenRequest( + fields: ClientCredentialAccountFields, + host: string +): Promise<{ body: string; hintFor: (body: string) => string | undefined }> { + if (resolveSalesforceAuthMethod(fields.authMethod) === 'jwt_bearer') { + const consumerKey = fields.clientId.trim() + const username = fields.username?.trim() + const privateKeyPem = fields.privateKey?.trim() + if (!username || !privateKeyPem) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: 'jwt_field_validation', + host, + reason: 'JWT bearer requires both a run-as username and a private key', + }) + } + const assertion = await buildSalesforceJwtAssertion( + consumerKey, + username, + host, + loadSalesforcePrivateKey(privateKeyPem) + ) + return { + body: new URLSearchParams({ + grant_type: JWT_BEARER_GRANT_TYPE, + assertion, + }).toString(), + hintFor: salesforceJwtErrorHint, + } + } + + const clientSecret = fields.clientSecret?.trim() + if (!clientSecret) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: 'client_credentials_field_validation', + host, + reason: 'client credentials requires a consumer secret', + }) + } + return { + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: fields.clientId, + client_secret: clientSecret, + }).toString(), + hintFor: salesforceErrorHint, + } +} + +/** + * Mints a Salesforce access token against the org's own My Domain token + * endpoint (`https://{host}/services/oauth2/token`), using either the OAuth + * 2.0 Client Credentials Flow or the JWT Bearer Flow depending on the + * credential's `authMethod`. login.salesforce.com hard-rejects the + * client-credentials grant, and Spring '26 made the My Domain host the only + * audience an External Client App accepts, so both grants target the org host. + * It is SSRF-guarded against the My Domain allowlist before any outbound + * fetch, and — because the assertion's `aud` is that same validated host — the + * guard also bounds where a signed assertion can be replayed. * * Salesforce reports every credential/configuration failure as HTTP 400 with * `{ error, error_description }` (invalid_client_id, invalid_client, - * invalid_grant), so 4xx maps to `invalid_credentials` — except transient - * 429/408 throttling statuses, which map to `provider_unavailable` alongside - * 5xx/network failures (never blame the credentials for provider-side - * throttling). A host that fails DNS resolution maps to `site_not_found` - * (the pasted My Domain host is wrong, not Salesforce down). The response - * carries no `expires_in` and no refresh token — see + * invalid_grant, app_not_found), so 4xx maps to `invalid_credentials` — except + * transient 429/408 throttling statuses, which map to `provider_unavailable` + * alongside 5xx/network failures (never blame the credentials for + * provider-side throttling). A host that fails DNS resolution maps to + * `site_not_found` (the pasted My Domain host is wrong, not Salesforce down). + * Neither grant returns `expires_in` or a refresh token — see * {@link SALESFORCE_TOKEN_TTL_SECONDS}. */ export async function mintSalesforceServiceAccountToken( @@ -221,18 +436,16 @@ export async function mintSalesforceServiceAccountToken( }) } + const { body: requestBody, hintFor } = await buildSalesforceTokenRequest(fields, host) + const res = await fetchProvider( `https://${host}/services/oauth2/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - grant_type: 'client_credentials', - client_id: fields.clientId, - client_secret: fields.clientSecret, - }).toString(), + body: requestBody, }, - 'salesforce_token_mint', + TOKEN_MINT_STEP, { dnsFailureCode: 'site_not_found', dnsFailureReason: 'host does not resolve — check the My Domain host', @@ -242,25 +455,25 @@ export async function mintSalesforceServiceAccountToken( if (!res.ok) { const body = await readProviderErrorSnippet(res) if (res.status >= 400 && res.status < 500 && !isTransientProviderStatus(res.status)) { - const hint = salesforceErrorHint(body) + const hint = hintFor(body) throw new TokenServiceAccountValidationError('invalid_credentials', res.status, { - step: 'salesforce_token_mint', + step: TOKEN_MINT_STEP, host, body, ...(hint ? { hint } : {}), }) } throw new TokenServiceAccountValidationError('provider_unavailable', res.status, { - step: 'salesforce_token_mint', + step: TOKEN_MINT_STEP, host, body, }) } - const payload = await parseProviderJson(res, 'salesforce_token_mint') + const payload = await parseProviderJson(res, TOKEN_MINT_STEP) if (typeof payload.access_token !== 'string' || !payload.access_token) { throw new TokenServiceAccountValidationError('provider_unavailable', 502, { - step: 'salesforce_token_mint', + step: TOKEN_MINT_STEP, host, reason: 'token response missing access_token', }) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.ts index 7ceff1748a9..32f776ff33d 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.ts @@ -18,6 +18,7 @@ import { isTransientProviderStatus, parseProviderJson, readProviderErrorSnippet, + requireClientSecret, TokenServiceAccountValidationError, } from '@/lib/credentials/token-service-accounts/errors' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' @@ -175,6 +176,8 @@ export async function mintZohoDeskServiceAccountToken( .filter((s) => s.startsWith('Desk.')) .join(',') + const clientSecret = requireClientSecret(fields.clientSecret, STEP, 'Zoho Desk') + const res = await fetchProvider( `${dataCenter.accountsBase}/oauth/v2/token`, { @@ -183,7 +186,7 @@ export async function mintZohoDeskServiceAccountToken( body: new URLSearchParams({ grant_type: 'client_credentials', client_id: fields.clientId, - client_secret: fields.clientSecret, + client_secret: clientSecret, scope, soid, }).toString(), diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.ts index 218eee37c1e..4d86f95da32 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoom.ts @@ -9,6 +9,7 @@ import { isTransientProviderStatus, parseProviderJson, readProviderErrorSnippet, + requireClientSecret, TokenServiceAccountValidationError, } from '@/lib/credentials/token-service-accounts/errors' @@ -59,7 +60,8 @@ export async function mintZoomServiceAccountToken( fields: ClientCredentialAccountFields, options?: ClientCredentialAccountMintOptions ): Promise { - const basicAuth = Buffer.from(`${fields.clientId}:${fields.clientSecret}`).toString('base64') + const clientSecret = requireClientSecret(fields.clientSecret, 'zoom_token_mint', 'Zoom') + const basicAuth = Buffer.from(`${fields.clientId}:${clientSecret}`).toString('base64') const res = await fetchProvider( ZOOM_TOKEN_URL, { diff --git a/apps/sim/lib/credentials/client-credential-accounts/server.test.ts b/apps/sim/lib/credentials/client-credential-accounts/server.test.ts index d0ded4e6801..24bfb0c4e25 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/server.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/server.test.ts @@ -57,4 +57,39 @@ describe('parseClientCredentialAccountSecretBlob', () => { MALFORMED ) }) + + it('accepts a key-based blob that carries a private key instead of a client secret', () => { + const parsed = parseClientCredentialAccountSecretBlob( + blob({ + providerId: 'salesforce-service-account', + clientSecret: undefined, + authMethod: 'jwt_bearer', + privateKey: '-----BEGIN PRIVATE KEY-----', + username: 'integration.user@acme.com', + }), + 'salesforce-service-account' + ) + expect(parsed.clientSecret).toBeUndefined() + expect(parsed.authMethod).toBe('jwt_bearer') + }) + + it('still rejects a blob carrying neither a client secret nor a private key', () => { + expect(() => + parseClientCredentialAccountSecretBlob( + blob({ providerId: 'salesforce-service-account', clientSecret: undefined }), + 'salesforce-service-account' + ) + ).toThrow(MALFORMED) + }) + + it('parses a pre-JWT salesforce blob unchanged', () => { + // Credentials created before the JWT branch existed carry no `authMethod`; + // they must keep resolving to the client-credentials grant. + const parsed = parseClientCredentialAccountSecretBlob( + blob({ providerId: 'salesforce-service-account' }), + 'salesforce-service-account' + ) + expect(parsed.clientSecret).toBe('secret') + expect(parsed.authMethod).toBeUndefined() + }) }) diff --git a/apps/sim/lib/credentials/client-credential-accounts/server.ts b/apps/sim/lib/credentials/client-credential-accounts/server.ts index 2a1f6bd2214..d34cfbfd1e3 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/server.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/server.ts @@ -16,7 +16,11 @@ import type { ServiceAccountPrincipal } from '@/lib/credentials/principal' /** Raw fields a client-credential minter receives (already trimmed). */ export interface ClientCredentialAccountFields { clientId: string - clientSecret: string + /** + * Absent only when the provider's selected {@link authMethod} authenticates + * with key material instead of a shared secret (Salesforce JWT bearer). + */ + clientSecret?: string /** * Provider-specific org identifier (Zoom Account ID, Box Enterprise ID, * Salesforce My Domain host, Zoho Desk organization ID). @@ -28,6 +32,20 @@ export interface ClientCredentialAccountFields { * ignores it, and a blank value keeps the provider's default region. */ dataCenter?: string + /** + * Which grant the provider's minter should use, for providers that offer + * more than one. Only Salesforce does (`client_credentials` | `jwt_bearer`); + * every other minter ignores it. Absent means the provider's default, which + * is what credentials created before the field existed carry. + */ + authMethod?: string + /** + * PEM private key signing the assertion, for key-based grants (Salesforce + * JWT bearer). Mutually exclusive with {@link clientSecret} in practice. + */ + privateKey?: string + /** Username a key-based grant authenticates as (Salesforce JWT `sub`). */ + username?: string } /** Identity derived from a successful mint, used at connect time. */ @@ -122,10 +140,15 @@ export interface ClientCredentialAccountSecretBlob { type: typeof CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE providerId: string clientId: string - clientSecret: string + /** Absent on key-based credentials, which carry a {@link privateKey} instead. */ + clientSecret?: string orgId: string /** Optional region selector; absent on every credential created before it existed. */ dataCenter?: string + /** Absent on every credential created before multi-grant support existed. */ + authMethod?: string + privateKey?: string + username?: string metadata?: Record } @@ -143,12 +166,13 @@ export function parseClientCredentialAccountSecretBlob( if (typeof parsed !== 'object' || parsed === null) { throw malformed } + // Requiring `clientSecret` outright would reject every key-based credential. if ( parsed.type !== CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE || parsed.providerId !== expectedProviderId || !parsed.clientId || - !parsed.clientSecret || - !parsed.orgId + !parsed.orgId || + (!parsed.clientSecret && !parsed.privateKey) ) { throw malformed } diff --git a/apps/sim/lib/credentials/connect-draft.ts b/apps/sim/lib/credentials/connect-draft.ts index 1298219df66..2e72f796526 100644 --- a/apps/sim/lib/credentials/connect-draft.ts +++ b/apps/sim/lib/credentials/connect-draft.ts @@ -4,7 +4,7 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, lt } from 'drizzle-orm' import { defaultCredentialDisplayName } from '@/lib/credentials/display-name' -import { getAllOAuthServices } from '@/lib/oauth/utils' +import { credentialProviderMatchesService, getAllOAuthServices } from '@/lib/oauth/utils' const logger = createLogger('OAuthConnectDraft') const DRAFT_TTL_MS = 15 * 60 * 1000 @@ -26,7 +26,12 @@ export async function createConnectDraft(params: { let displayName = params.displayName if (!displayName) { - const service = getAllOAuthServices().find((s) => s.providerId === providerId) + // Matches through the canonical predicate so an alternate authorization + // server's id resolves the service's real name — otherwise the default + // label reads "My salesforce-sandbox". + const service = getAllOAuthServices().find((s) => + credentialProviderMatchesService(providerId, s) + ) const serviceName = service?.name ?? providerId let userName: string | null = null diff --git a/apps/sim/lib/credentials/oauth-chat-attempt.test.ts b/apps/sim/lib/credentials/oauth-chat-attempt.test.ts index 99ae6e78bb1..5a1b97e0126 100644 --- a/apps/sim/lib/credentials/oauth-chat-attempt.test.ts +++ b/apps/sim/lib/credentials/oauth-chat-attempt.test.ts @@ -10,6 +10,7 @@ import { createOAuthChatAttempt, getOAuthCredentialBaseline, hasOAuthCredentialChanged, + hasOAuthCredentialForTarget, OAUTH_CHAT_ATTEMPT_EVENT, OAUTH_CHAT_ATTEMPT_PARAM, OAUTH_CHAT_COMPLETE_PATH, @@ -254,3 +255,88 @@ describe('OAuth chat attempts', () => { ).toBe(true) }) }) + +describe('credential matching for a chat chip', () => { + const credential = (providerId: string) => ({ + id: `cred-${providerId}`, + providerId, + updatedAt: '2026-08-11T00:00:00.000Z', + }) + + it('matches a credential from an alternate authorization server', () => { + // A sandbox-only user is connected; without this the chip reads as + // disconnected and re-prompts them to connect Salesforce again. + expect( + hasOAuthCredentialForTarget( + { + providerId: 'salesforce', + baseProviderId: 'salesforce', + additionalProviderIds: ['salesforce-sandbox'], + }, + [credential('salesforce-sandbox')] + ) + ).toBe(true) + }) + + it('still matches the primary id and the base provider', () => { + const target = { providerId: 'google-email', baseProviderId: 'google' } + expect(hasOAuthCredentialForTarget(target, [credential('google-email')])).toBe(true) + expect(hasOAuthCredentialForTarget(target, [credential('google')])).toBe(true) + }) + + it('does not match an unrelated provider', () => { + expect( + hasOAuthCredentialForTarget( + { + providerId: 'salesforce', + baseProviderId: 'salesforce', + additionalProviderIds: ['salesforce-sandbox'], + }, + [credential('hubspot')] + ) + ).toBe(false) + }) + + it('honours an explicit credentialId over any provider match', () => { + expect( + hasOAuthCredentialForTarget( + { + providerId: 'salesforce', + baseProviderId: 'salesforce', + credentialId: 'cred-other', + additionalProviderIds: ['salesforce-sandbox'], + }, + [credential('salesforce-sandbox')] + ) + ).toBe(false) + }) +}) + +describe('attempt carries the alternate provider ids through verification', () => { + it('lets hasOAuthCredentialChanged see a credential from an alternate server', () => { + // The post-connect leg re-reads the STORED attempt, not the live target, so + // the ids must survive the round trip or a sandbox connect reads as failed. + const attempt = createOAuthChatAttempt({ + workspaceId: 'workspace-1', + providerId: 'salesforce', + baseProviderId: 'salesforce', + additionalProviderIds: ['salesforce-sandbox'], + displayName: 'Salesforce', + controlId: 'control-1', + baselineCredentialIds: [], + }) + + const stored = readOAuthChatAttempt(attempt.id) + expect(stored?.additionalProviderIds).toEqual(['salesforce-sandbox']) + + expect( + hasOAuthCredentialChanged(stored as NonNullable, [ + { + id: 'cred-sandbox', + providerId: 'salesforce-sandbox', + updatedAt: '2026-08-11T00:00:00.000Z', + }, + ]) + ).toBe(true) + }) +}) diff --git a/apps/sim/lib/credentials/oauth-chat-attempt.ts b/apps/sim/lib/credentials/oauth-chat-attempt.ts index 0a6fd6bed3e..25bad7ff804 100644 --- a/apps/sim/lib/credentials/oauth-chat-attempt.ts +++ b/apps/sim/lib/credentials/oauth-chat-attempt.ts @@ -24,6 +24,13 @@ export interface OAuthCredentialTarget { providerId: string baseProviderId: string credentialId?: string + /** + * Alternate authorization servers whose credentials authenticate this same + * service (`salesforce-sandbox`). Supplied by the caller rather than resolved + * here so this module stays free of the OAuth provider registry, which would + * otherwise pull icon components into the chat bundle. + */ + additionalProviderIds?: readonly string[] } export interface OAuthCredentialBaseline { @@ -36,6 +43,15 @@ export interface OAuthChatAttempt { workspaceId: string providerId: string baseProviderId: string + /** + * See {@link OAuthCredentialTarget.additionalProviderIds}. Persisted on the + * attempt because the post-connect verification leg re-reads the stored + * attempt rather than the live target — without it, a credential from an + * alternate authorization server would not register as "connected". + * Absent on attempts written before this existed; those simply match as they + * did, and attempts expire after {@link OAUTH_CHAT_ATTEMPT_MAX_AGE_MS}. + */ + additionalProviderIds?: readonly string[] displayName: string controlId: string credentialId?: string @@ -49,6 +65,8 @@ interface CreateOAuthChatAttemptInput { workspaceId: string providerId: string baseProviderId: string + /** See {@link OAuthCredentialTarget.additionalProviderIds}. */ + additionalProviderIds?: readonly string[] displayName: string controlId: string credentialId?: string @@ -65,7 +83,10 @@ function credentialsForTarget( } return credentials.filter( (credential) => - credential.providerId === target.providerId || credential.providerId === target.baseProviderId + credential.providerId === target.providerId || + credential.providerId === target.baseProviderId || + (credential.providerId !== null && + (target.additionalProviderIds?.includes(credential.providerId) ?? false)) ) } diff --git a/apps/sim/lib/credentials/orchestration/index.test.ts b/apps/sim/lib/credentials/orchestration/index.test.ts index 18e0cfb0d1d..c4a5bee7715 100644 --- a/apps/sim/lib/credentials/orchestration/index.test.ts +++ b/apps/sim/lib/credentials/orchestration/index.test.ts @@ -10,12 +10,16 @@ const { mockDecryptSecret, mockVerifyAndBuildServiceAccountSecret, mockIsClientCredentialAccountProviderId, + mockGetClientCredentialAccountDescriptor, } = vi.hoisted(() => ({ mockRecordAudit: vi.fn(), mockGetCredentialActorContext: vi.fn(), mockDecryptSecret: vi.fn(), mockVerifyAndBuildServiceAccountSecret: vi.fn(), mockIsClientCredentialAccountProviderId: vi.fn(() => false), + // Only a descriptor carrying `defaultAuthMethod` is multi-grant; single-grant + // providers must not trigger the stored-blob read for authMethod/username. + mockGetClientCredentialAccountDescriptor: vi.fn(() => undefined), })) vi.mock('@sim/audit', () => ({ @@ -33,6 +37,7 @@ vi.mock('@/lib/credentials/service-account-secret', () => ({ })) vi.mock('@/lib/credentials/client-credential-accounts/descriptors', () => ({ isClientCredentialAccountProviderId: mockIsClientCredentialAccountProviderId, + getClientCredentialAccountDescriptor: mockGetClientCredentialAccountDescriptor, })) vi.mock('@/lib/credentials/deletion', () => ({ deleteCredential: vi.fn() })) vi.mock('@/lib/credentials/environment', () => ({ @@ -104,6 +109,7 @@ describe('performUpdateCredential — service-account secret rotation', () => { vi.clearAllMocks() resetDbChainMock() mockIsClientCredentialAccountProviderId.mockReturnValue(false) + mockGetClientCredentialAccountDescriptor.mockReturnValue(undefined) mockVerifyAndBuildServiceAccountSecret.mockResolvedValue({ providerId: 'google-service-account', encryptedServiceAccountKey: 'new-cipher', @@ -277,6 +283,68 @@ describe('performUpdateCredential — service-account secret rotation', () => { ) }) + it('carries the stored auth method and username forward on a key rotation', async () => { + // Rotating a Salesforce JWT key resubmits only the key. Losing the stored + // grant would silently mint the credential as client credentials instead. + mockCredential({ providerId: 'salesforce-service-account', displayName: 'SF integration' }) + mockIsClientCredentialAccountProviderId.mockReturnValue(true) + mockGetClientCredentialAccountDescriptor.mockReturnValue({ + defaultAuthMethod: 'client_credentials', + } as never) + mockStoredBlob({ + type: 'client_credential_account', + authMethod: 'jwt_bearer', + username: 'integration.user@acme.com', + }) + mockVerifyAndBuildServiceAccountSecret.mockResolvedValue({ + providerId: 'salesforce-service-account', + encryptedServiceAccountKey: 'new-cipher', + displayName: 'SF integration', + auditMetadata: {}, + }) + + await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + clientId: 'cid', + orgId: 'acme.my.salesforce.com', + privateKey: '-----BEGIN PRIVATE KEY-----rotated', + }) + + expect(mockVerifyAndBuildServiceAccountSecret).toHaveBeenCalledWith( + 'salesforce-service-account', + expect.objectContaining({ + authMethod: 'jwt_bearer', + username: 'integration.user@acme.com', + privateKey: '-----BEGIN PRIVATE KEY-----rotated', + }) + ) + }) + + it('does not read the stored blob for a single-grant client-credential reconnect', async () => { + // Zoom/Box/Zoho have no auth method to carry forward, so a reconnect that + // supplies its own dataCenter must not pay for a decrypt. + mockCredential({ providerId: 'zoom-service-account', displayName: 'Zoom S2S' }) + mockIsClientCredentialAccountProviderId.mockReturnValue(true) + mockVerifyAndBuildServiceAccountSecret.mockResolvedValue({ + providerId: 'zoom-service-account', + encryptedServiceAccountKey: 'new-cipher', + displayName: 'Zoom S2S', + auditMetadata: {}, + }) + + await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + clientId: 'cid', + clientSecret: 'csec', + orgId: 'acct-1', + dataCenter: 'us', + }) + + expect(mockDecryptSecret).not.toHaveBeenCalled() + }) + it('surfaces a rebuild failure as a validation error and writes nothing', async () => { mockCredential() mockStoredBlob({ type: 'service_account', client_email: OLD_EMAIL }) diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index b36ca844047..5e840ae4e1d 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -8,7 +8,10 @@ import type { NextRequest } from 'next/server' import { decryptSecret } from '@/lib/core/security/encryption' import { getCredentialActorContext } from '@/lib/credentials/access' import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account' -import { isClientCredentialAccountProviderId } from '@/lib/credentials/client-credential-accounts/descriptors' +import { + getClientCredentialAccountDescriptor, + isClientCredentialAccountProviderId, +} from '@/lib/credentials/client-credential-accounts/descriptors' import { type CredentialDeleteReason, deleteCredential } from '@/lib/credentials/deletion' import { slackCustomBotDisplayName } from '@/lib/credentials/display-name' import { @@ -71,13 +74,17 @@ async function readStoredSecretBlob(credentialId: string): Promise | null): string | undefined { - const dataCenter = blob?.dataCenter - return typeof dataCenter === 'string' && dataCenter ? dataCenter : undefined +function readStoredField( + blob: Record | null, + field: 'dataCenter' | 'authMethod' | 'username' +): string | undefined { + const value = blob?.[field] + return typeof value === 'string' && value ? value : undefined } /** @@ -130,6 +137,9 @@ export interface PerformUpdateCredentialParams extends CredentialActorParams { clientSecret?: string orgId?: string dataCenter?: string + authMethod?: string + privateKey?: string + username?: string } export interface PerformCredentialResult { @@ -190,7 +200,10 @@ export async function performUpdateCredential( params.clientId !== undefined || params.clientSecret !== undefined || params.orgId !== undefined || - params.dataCenter !== undefined + params.dataCenter !== undefined || + params.authMethod !== undefined || + params.privateKey !== undefined || + params.username !== undefined let rotatedSlackBotUserId: string | undefined let rotatedAuditMetadata: Record | undefined if (hasRotationSecret && access.credential.type === 'service_account') { @@ -202,8 +215,15 @@ export async function performUpdateCredential( // like the Zoho data center would be silently dropped, moving an EU/IN/AU // credential back to the US accounts server. Carry the stored value forward // when the caller did not supply one. - const needsStoredDataCenter = - params.dataCenter === undefined && isClientCredentialAccountProviderId(providerId) + const isClientCredentialProvider = isClientCredentialAccountProviderId(providerId) + const needsStoredDataCenter = params.dataCenter === undefined && isClientCredentialProvider + // Only a multi-grant provider stores these, so single-grant ones must not + // pay for a row read + decrypt that can only ever return undefined. + const isMultiGrantProvider = Boolean( + getClientCredentialAccountDescriptor(providerId)?.defaultAuthMethod + ) + const needsStoredAuthMethod = params.authMethod === undefined && isMultiGrantProvider + const needsStoredUsername = params.username === undefined && isMultiGrantProvider // Rotating to a key that belongs to a different principal makes an // identity-derived label (a Google `client_email`, a Slack team name) @@ -216,7 +236,7 @@ export async function performUpdateCredential( // One read + decrypt at most, and only for the providers that can use it. const storedBlob = - needsStoredDataCenter || needsStoredIdentity + needsStoredDataCenter || needsStoredAuthMethod || needsStoredUsername || needsStoredIdentity ? await readStoredSecretBlob(access.credential.id) : null @@ -230,7 +250,14 @@ export async function performUpdateCredential( clientId: params.clientId, clientSecret: params.clientSecret, orgId: params.orgId, - dataCenter: needsStoredDataCenter ? readStoredDataCenter(storedBlob) : params.dataCenter, + dataCenter: needsStoredDataCenter + ? readStoredField(storedBlob, 'dataCenter') + : params.dataCenter, + authMethod: needsStoredAuthMethod + ? readStoredField(storedBlob, 'authMethod') + : params.authMethod, + privateKey: params.privateKey, + username: needsStoredUsername ? readStoredField(storedBlob, 'username') : params.username, }) updates.encryptedServiceAccountKey = secret.encryptedServiceAccountKey rotatedSlackBotUserId = secret.botUserId diff --git a/apps/sim/lib/credentials/service-account-fields.ts b/apps/sim/lib/credentials/service-account-fields.ts index 5502b86c1d5..fe1972a6c02 100644 --- a/apps/sim/lib/credentials/service-account-fields.ts +++ b/apps/sim/lib/credentials/service-account-fields.ts @@ -17,6 +17,9 @@ export type ServiceAccountFieldId = | 'clientSecret' | 'orgId' | 'dataCenter' + | 'authMethod' + | 'privateKey' + | 'username' /** * Required create-body fields per service-account provider — the client-safe diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index d6b678d4a17..4667f4cf431 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -8,10 +8,14 @@ import { } from '@/lib/credentials/atlassian-service-account' import { CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE, + type ClientCredentialAccountFieldId, getClientCredentialAccountDescriptor, isClientCredentialAccountProviderId, + partitionClientCredentialFields, + resolveClientCredentialAuthMethod, } from '@/lib/credentials/client-credential-accounts/descriptors' import { + type ClientCredentialAccountFields, type ClientCredentialAccountSecretBlob, getClientCredentialAccountMinter, } from '@/lib/credentials/client-credential-accounts/server' @@ -49,6 +53,9 @@ export interface ServiceAccountSecretFields { clientSecret?: string orgId?: string dataCenter?: string + authMethod?: string + privateKey?: string + username?: string } export interface ServiceAccountSecretResult { @@ -273,20 +280,40 @@ async function buildClientCredentialAccountSecret( `No minter registered for service-account provider ${providerId}` ) } - const clientId = fields.clientId?.trim() - const clientSecret = fields.clientSecret?.trim() - const orgId = fields.orgId?.trim() - const dataCenter = fields.dataCenter?.trim() - if (!clientId || !clientSecret || !orgId) { - const required = descriptor.fields - .filter((field) => !field.optional) - .map((field) => field.id) - .join(', ') + // The resolved (never the raw) method drives both validation and what gets + // persisted, so a credential's stored grant can't drift if the descriptor's + // default ever changes. + const resolvedAuthMethod = resolveClientCredentialAuthMethod( + descriptor, + fields.authMethod?.trim() + ) + const { visible, required } = partitionClientCredentialFields(descriptor, resolvedAuthMethod) + const usesField = (id: ClientCredentialAccountFieldId) => visible.some((field) => field.id === id) + + // A field the resolved grant does not use is dropped rather than stored, so + // a request carrying both a consumer secret and a private key cannot leave + // the unused one encrypted at rest on the credential. + const submitted: ClientCredentialAccountFields = { + clientId: fields.clientId?.trim() ?? '', + orgId: fields.orgId?.trim() ?? '', + dataCenter: fields.dataCenter?.trim() || undefined, + authMethod: resolvedAuthMethod, + clientSecret: usesField('clientSecret') ? fields.clientSecret?.trim() || undefined : undefined, + privateKey: usesField('privateKey') ? fields.privateKey?.trim() || undefined : undefined, + username: usesField('username') ? fields.username?.trim() || undefined : undefined, + } + + // Requirements are per-auth-method, not per-provider: Salesforce's JWT + // branch needs a private key and username where its client-credentials + // branch needs a consumer secret. The contract schema validates the + // union-of-both shape, so the branch-specific check has to happen here. + const missing = required.filter((field) => !submitted[field.id]) + if (missing.length > 0) { throw new ServiceAccountSecretError( - `${required} are required for ${descriptor.serviceLabel} service account credentials` + `${missing.map((field) => field.label).join(', ')} ${missing.length > 1 ? 'are' : 'is'} required for ${descriptor.serviceLabel} service account credentials` ) } - const mint = await minter({ clientId, clientSecret, orgId, dataCenter }) + const mint = await minter(submitted) // `identity` is absent only on the `skipIdentity` execution-time path, which // never reaches this builder; treat it as "no principal captured". const principal = mint.identity?.principal ?? null @@ -294,17 +321,14 @@ async function buildClientCredentialAccountSecret( const blob: ClientCredentialAccountSecretBlob = { type: CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE, providerId, - clientId, - clientSecret, - orgId, - ...(dataCenter ? { dataCenter } : {}), + ...submitted, metadata: { ...mint.identity?.storedMetadata, ...principalMetadata }, } const { encrypted } = await encryptSecret(JSON.stringify(blob)) return { providerId, encryptedServiceAccountKey: encrypted, - displayName: mint.identity?.displayName ?? `${descriptor.serviceLabel} ${orgId}`, + displayName: mint.identity?.displayName ?? `${descriptor.serviceLabel} ${submitted.orgId}`, auditMetadata: { ...mint.identity?.auditMetadata, ...principalMetadata }, principal, } diff --git a/apps/sim/lib/credentials/token-service-accounts/errors.ts b/apps/sim/lib/credentials/token-service-accounts/errors.ts index 3e6ec4cbed6..d7d53315812 100644 --- a/apps/sim/lib/credentials/token-service-accounts/errors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/errors.ts @@ -24,6 +24,29 @@ export class TokenServiceAccountValidationError extends Error { const ERROR_SNIPPET_MAX_LENGTH = 500 +/** + * Narrows the optional `clientSecret` for the single-grant client-credential + * providers that always require one. `ClientCredentialAccountFields` makes it + * optional for the key-based grants (Salesforce JWT bearer), so providers with + * no such branch re-state the invariant here. The secret builder's + * required-field check already rejects a missing value at connect time; this + * fails loudly rather than posting `client_secret=undefined` if a future + * caller ever bypasses it. + */ +export function requireClientSecret( + clientSecret: string | undefined, + step: string, + serviceLabel: string +): string { + if (!clientSecret) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step, + reason: `${serviceLabel} requires a client secret`, + }) + } + return clientSecret +} + /** * Short, stable description of a failed best-effort provider call, for callers * that degrade instead of throwing. `TokenServiceAccountValidationError`'s diff --git a/apps/sim/lib/integrations/credential-display.ts b/apps/sim/lib/integrations/credential-display.ts index 0dc2bcf5bfb..5bf924a611b 100644 --- a/apps/sim/lib/integrations/credential-display.ts +++ b/apps/sim/lib/integrations/credential-display.ts @@ -63,9 +63,10 @@ const SERVICE_ACCOUNT_PROVIDER_IDS: ReadonlySet = new Set( * `OAUTH_PROVIDERS`, which is wasted work to repeat per lookup (same reasoning * as `SERVICE_ACCOUNT_INTEGRATIONS` in `oauth-service.ts`). * - * Indexing under both ids a service answers to is the predicate - * {@link credentialProviderMatchesService} expressed as a map, so the two can - * never disagree. + * Indexing under every id a service answers to — its OAuth provider id, its + * service-account provider id, and any additional authorization server — is + * the predicate {@link credentialProviderMatchesService} expressed as a map, + * so the two can never disagree. */ const INTEGRATIONS_BY_CREDENTIAL_PROVIDER: ReadonlyMap = (() => { const index = new Map() @@ -82,6 +83,9 @@ const INTEGRATIONS_BY_CREDENTIAL_PROVIDER: ReadonlyMap { providerId: 'salesforce', endpoint: 'https://login.salesforce.com/services/oauth2/token', }, + { + // A sandbox refresh token is only redeemable at the authorization + // server that issued it; posting it to login.salesforce.com fails. + name: 'Salesforce sandbox', + providerId: 'salesforce-sandbox', + endpoint: 'https://test.salesforce.com/services/oauth2/token', + }, { name: 'Shopify', providerId: 'shopify', @@ -272,10 +279,18 @@ describe('OAuth Token Refresh', () => { expect(bodyParams.get('grant_type')).toBe('refresh_token') expect(bodyParams.get('refresh_token')).toBe(refreshToken) - const expectedClientId = - providerId === 'outlook' ? 'microsoft_client_id' : `${providerId}_client_id` - const expectedClientSecret = - providerId === 'outlook' ? 'microsoft_client_secret' : `${providerId}_client_secret` + // Two provider ids deliberately borrow another's OAuth client: + // `outlook` shares Microsoft's, and `salesforce-sandbox` shares + // Salesforce's (one Connected App's consumer key is valid at both + // login.salesforce.com and test.salesforce.com). + const clientEnvPrefix = + providerId === 'outlook' + ? 'microsoft' + : providerId === 'salesforce-sandbox' + ? 'salesforce' + : providerId + const expectedClientId = `${clientEnvPrefix}_client_id` + const expectedClientSecret = `${clientEnvPrefix}_client_secret` expect(bodyParams.get('client_id')).toBe(expectedClientId) expect(bodyParams.get('client_secret')).toBe(expectedClientSecret) diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 2e2e629c914..bb7a6b255ec 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -71,6 +71,11 @@ import { readResponseTextWithLimit, } from '@/lib/core/utils/stream-limits' import { parseInstagramLongLivedToken } from '@/lib/oauth/instagram' +import { + SALESFORCE_ADDITIONAL_PROVIDER_IDS, + SALESFORCE_LOGIN_HOSTS, + SALESFORCE_PROVIDER_ID_LABELS, +} from '@/lib/oauth/salesforce' import type { OAuthProviderConfig } from './types' const logger = createLogger('OAuth') @@ -1114,6 +1119,9 @@ export const OAUTH_PROVIDERS: Record = { name: 'Salesforce', description: 'Access and manage your Salesforce CRM data.', providerId: 'salesforce', + additionalProviderIds: SALESFORCE_ADDITIONAL_PROVIDER_IDS, + providerIdLabels: SALESFORCE_PROVIDER_ID_LABELS, + providerIdPickerHint: 'Sandbox orgs sign in at test.salesforce.com, not production.', serviceAccountProviderId: 'salesforce-service-account', icon: SalesforceIcon, baseProviderIcon: SalesforceIcon, @@ -1637,14 +1645,19 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig { refreshStrategy: 'instagram_long_lived', } } - case 'salesforce': { + case 'salesforce': + case 'salesforce-sandbox': { const { clientId, clientSecret } = getConfiguredClientCredentials( 'salesforce', 'SALESFORCE_CLIENT_ID', 'SALESFORCE_CLIENT_SECRET' ) + // A refresh token is only redeemable at the authorization server that + // issued it: a sandbox token posted to login.salesforce.com fails with + // `invalid_grant`. One Connected App's consumer key is valid at both + // hosts, so only the endpoint differs. return { - tokenEndpoint: 'https://login.salesforce.com/services/oauth2/token', + tokenEndpoint: `https://${SALESFORCE_LOGIN_HOSTS[provider]}/services/oauth2/token`, clientId, clientSecret, useBasicAuth: false, @@ -1789,6 +1802,17 @@ function buildAuthRequest( return { headers, bodyParams, useJsonBody: config.useJsonBody } } +/** + * Resolves the key {@link getProviderAuthConfig} is switched on for a stored + * credential's provider id. + * + * Normally that is the base provider, because every service in a family + * refreshes against the same endpoint with the same client. A provider id + * listed in a service's `additionalProviderIds` is the exception: it names a + * *different* authorization server for the same service, so it must reach + * `getProviderAuthConfig` intact — collapsing it to the base would silently + * refresh a sandbox token against the production endpoint. + */ function getBaseProviderForService(providerId: string): string { if (providerId in OAUTH_PROVIDERS) { return providerId @@ -1799,6 +1823,9 @@ function getBaseProviderForService(providerId: string): string { if (service.providerId === providerId) { return baseProvider } + if (service.additionalProviderIds?.includes(providerId)) { + return providerId + } } } diff --git a/apps/sim/lib/oauth/salesforce.ts b/apps/sim/lib/oauth/salesforce.ts new file mode 100644 index 00000000000..d5890802f3d --- /dev/null +++ b/apps/sim/lib/oauth/salesforce.ts @@ -0,0 +1,96 @@ +/** + * Shared Salesforce OAuth helpers for the two connector provider ids. + * + * Salesforce runs two authorization servers — `login.salesforce.com` for + * production and Developer Edition orgs, `test.salesforce.com` for sandboxes — + * and a user in one cannot authenticate against the other. Better Auth's + * `genericOAuth` takes static endpoints, so each host is registered as its own + * provider, and `OAUTH_PROVIDERS.salesforce.services.salesforce.additionalProviderIds` + * maps the sandbox id back to the single Salesforce service. + * + * Every code path that special-cases Salesforce by provider id must go through + * {@link isSalesforceOAuthProviderId} rather than comparing to `'salesforce'`, + * or sandbox credentials silently lose the behaviour production ones get. + * + * Deliberately free of imports so both the Better Auth config and API route + * handlers can use it without dragging in the OAuth provider registry. + */ + +/** The provider id a Salesforce connection defaults to. */ +export const SALESFORCE_PRIMARY_PROVIDER_ID = 'salesforce' + +/** + * Every Salesforce authorization server, keyed by connector provider id. The + * single source for the connector registrations, the refresh endpoints, the + * `additionalProviderIds` that map them all onto one service, and the connect + * modal's environment picker — adding a host here reaches all four. + */ +const SALESFORCE_AUTH_SERVERS: Readonly> = { + [SALESFORCE_PRIMARY_PROVIDER_ID]: { + loginHost: 'login.salesforce.com', + label: 'Production or Developer Edition', + }, + 'salesforce-sandbox': { + loginHost: 'test.salesforce.com', + label: 'Sandbox', + }, +} + +/** Login host per Salesforce connector provider id. */ +export const SALESFORCE_LOGIN_HOSTS: Readonly> = Object.fromEntries( + Object.entries(SALESFORCE_AUTH_SERVERS).map(([providerId, server]) => [ + providerId, + server.loginHost, + ]) +) + +/** Non-default Salesforce provider ids, for the service's `additionalProviderIds`. */ +export const SALESFORCE_ADDITIONAL_PROVIDER_IDS: readonly string[] = Object.keys( + SALESFORCE_AUTH_SERVERS +).filter((providerId) => providerId !== SALESFORCE_PRIMARY_PROVIDER_ID) + +/** Environment-picker labels, for the service's `providerIdLabels`. */ +export const SALESFORCE_PROVIDER_ID_LABELS: Readonly> = Object.fromEntries( + Object.entries(SALESFORCE_AUTH_SERVERS).map(([providerId, server]) => [providerId, server.label]) +) + +/** Whether a stored credential's `providerId` is one of the Salesforce OAuth connectors. */ +export function isSalesforceOAuthProviderId(providerId: string | null | undefined): boolean { + return typeof providerId === 'string' && providerId in SALESFORCE_LOGIN_HOSTS +} + +/** + * Prefix under which the org's API instance URL is smuggled into Better Auth's + * `scope` column. The token response carries no `instance_url`, and the account + * row has nowhere else to put a provider-specific value. + */ +const SALESFORCE_INSTANCE_SCOPE_PREFIX = '__sf_instance__:' + +const SALESFORCE_INSTANCE_URL_REGEX = new RegExp(`^${SALESFORCE_INSTANCE_SCOPE_PREFIX}([^\\s]+)`) + +/** Value to store in `scope` so {@link extractSalesforceInstanceUrl} can read it back. */ +export function withSalesforceInstanceScope( + instanceUrl: string, + scope: string | null | undefined +): string { + return `${SALESFORCE_INSTANCE_SCOPE_PREFIX}${instanceUrl} ${scope ?? ''}` +} + +/** Reads back the instance URL stored by {@link withSalesforceInstanceScope}. */ +export function extractSalesforceInstanceUrl(scope: string | null | undefined): string | undefined { + return scope?.match(SALESFORCE_INSTANCE_URL_REGEX)?.[1] +} + +/** + * Origins that are an authorization server rather than an org's API host. A + * token minted at either can carry one in its `sub` claim, and calling + * `/services/data/...` against a login host always fails — so neither is ever a + * usable instance URL. + */ +const SALESFORCE_LOGIN_ORIGINS: ReadonlySet = new Set( + Object.values(SALESFORCE_LOGIN_HOSTS).map((host) => `https://${host}`) +) + +export function isSalesforceLoginOrigin(origin: string): boolean { + return SALESFORCE_LOGIN_ORIGINS.has(origin) +} diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index 0e71b4c4ba2..628ae367c49 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -162,6 +162,32 @@ export interface OAuthServiceConfig { scopes: string[] authType?: OAuthAuthType serviceAccountProviderId?: string + /** + * Further OAuth provider ids whose credentials authenticate this same + * service. Used when one integration is reachable through more than one + * authorization server and Better Auth therefore needs a separate static + * provider registration for each — Salesforce production + * (`login.salesforce.com`) versus sandbox (`test.salesforce.com`). + * + * Credentials stored under any of these ids resolve to this service, so they + * appear in the same block credential picker and group under the same + * integration. Distinct from {@link serviceAccountProviderId}, which is the + * one non-OAuth credential family the service accepts. + */ + additionalProviderIds?: readonly string[] + /** + * Labels for the connect modal's authorization-server picker, keyed by + * provider id and including the primary {@link providerId}. Required + * whenever {@link additionalProviderIds} is set — without it the picker has + * nothing to render and the alternate server is unreachable from the UI. + */ + providerIdLabels?: Readonly> + /** + * One-line guidance under the authorization-server picker. Earns its place + * because picking the wrong server fails as an ordinary bad-password error, + * which does not hint that the environment was the problem. + */ + providerIdPickerHint?: string } /** @@ -171,6 +197,7 @@ export interface OAuthServiceMetadata { serviceId: string providerId: string serviceAccountProviderId?: string + additionalProviderIds?: readonly string[] name: string description: string baseProvider: string diff --git a/apps/sim/lib/oauth/utils.test.ts b/apps/sim/lib/oauth/utils.test.ts index cc758eb90f2..656938fae49 100644 --- a/apps/sim/lib/oauth/utils.test.ts +++ b/apps/sim/lib/oauth/utils.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest' +import { OAUTH_PROVIDERS } from './oauth' import type { OAuthProvider, OAuthServiceMetadata } from './types' import { + canonicalizeServiceProviderId, + credentialProviderMatchesService, getAllOAuthServices, getCanonicalScopesForProvider, getMissingRequiredScopes, @@ -10,6 +13,7 @@ import { getServiceConfigByProviderId, getServiceConfigByServiceId, parseProvider, + providerIdsForService, } from './utils' describe('getAllOAuthServices', () => { @@ -721,3 +725,72 @@ describe('getMissingRequiredScopes', () => { expect(missing).toEqual([]) }) }) + +describe('providerIdsForService', () => { + it('widens a service primary id to its alternate authorization servers', () => { + // The SQL counterpart to credentialProviderMatchesService: the block + // picker queries by 'salesforce', and a sandbox credential is stored under + // 'salesforce-sandbox'. Without the widening it is filtered out at the DB + // and never reaches the picker, however correct the in-memory resolvers. + expect(providerIdsForService('salesforce')).toEqual(['salesforce', 'salesforce-sandbox']) + }) + + it('does not widen an alternate server id back into the primary', () => { + expect(providerIdsForService('salesforce-sandbox')).toEqual(['salesforce-sandbox']) + }) + + it('does not widen a service-account id into the OAuth family', () => { + // Broadening here would leak OAuth credentials into a service-account query. + expect(providerIdsForService('salesforce-service-account')).toEqual([ + 'salesforce-service-account', + ]) + }) + + it('returns a single-id list for providers with no alternate server', () => { + expect(providerIdsForService('hubspot')).toEqual(['hubspot']) + expect(providerIdsForService('not-a-real-provider')).toEqual(['not-a-real-provider']) + }) +}) + +describe('credentialProviderMatchesService', () => { + const salesforce = OAUTH_PROVIDERS.salesforce.services.salesforce + + it('matches the primary OAuth id, an alternate server, and the service account', () => { + expect(credentialProviderMatchesService('salesforce', salesforce)).toBe(true) + // The alternate-server clause: without it a sandbox credential is invisible + // to every surface that resolves a credential to its service. + expect(credentialProviderMatchesService('salesforce-sandbox', salesforce)).toBe(true) + expect(credentialProviderMatchesService('salesforce-service-account', salesforce)).toBe(true) + }) + + it('does not match an unrelated provider', () => { + expect(credentialProviderMatchesService('hubspot', salesforce)).toBe(false) + }) +}) + +describe('canonicalizeServiceProviderId', () => { + const salesforce = OAUTH_PROVIDERS.salesforce.services.salesforce + const gmail = OAUTH_PROVIDERS.google.services.gmail + + it('folds an alternate authorization server onto its service', () => { + expect(canonicalizeServiceProviderId('salesforce-sandbox', salesforce)).toBe('salesforce') + }) + + it('leaves the primary id untouched', () => { + expect(canonicalizeServiceProviderId('salesforce', salesforce)).toBe('salesforce') + }) + + it('never folds a family-wide service-account id onto one product', () => { + // `google-service-account` authenticates every Google service, so folding it + // onto whichever one matched first would mark exactly one as connected. + expect(canonicalizeServiceProviderId('google-service-account', gmail)).toBe( + 'google-service-account' + ) + }) + + it('leaves an id untouched when no service resolved', () => { + expect(canonicalizeServiceProviderId('salesforce-sandbox', undefined)).toBe( + 'salesforce-sandbox' + ) + }) +}) diff --git a/apps/sim/lib/oauth/utils.ts b/apps/sim/lib/oauth/utils.ts index 21ccaeadbd8..eef2b12d759 100644 --- a/apps/sim/lib/oauth/utils.ts +++ b/apps/sim/lib/oauth/utils.ts @@ -478,6 +478,7 @@ export function getAllOAuthServices(): OAuthServiceMetadata[] { serviceId, providerId: service.providerId, serviceAccountProviderId: service.serviceAccountProviderId, + additionalProviderIds: service.additionalProviderIds, name: service.name, description: service.description, baseProvider: baseProviderId, @@ -540,7 +541,8 @@ export function getServiceConfigByProviderId(providerId: string): OAuthServiceCo if ( service.providerId === providerId || key === providerId || - service.serviceAccountProviderId === providerId + service.serviceAccountProviderId === providerId || + service.additionalProviderIds?.includes(providerId) ) { return service } @@ -563,16 +565,20 @@ export function getServiceAccountProviderForProviderId(providerId: string): stri export interface ServiceProviderIdentity { providerId: string serviceAccountProviderId?: string + additionalProviderIds?: readonly string[] } /** * Whether a stored credential's `providerId` authenticates the given service. * - * A service is reachable by two ids: its own OAuth `providerId` (`jira`) and - * the service-account provider its family issues (`atlassian-service-account`). - * One Atlassian API token authenticates Jira, Jira Service Management, and - * Confluence alike, so matching on the OAuth `providerId` alone hides a - * service-account credential from every product page it actually powers. + * A service is reachable by its own OAuth `providerId` (`jira`), the + * service-account provider its family issues (`atlassian-service-account`), + * and any `additionalProviderIds` naming a second authorization server for the + * same service (`salesforce-sandbox`). One Atlassian API token authenticates + * Jira, Jira Service Management, and Confluence alike, so matching on the + * OAuth `providerId` alone hides a service-account credential from every + * product page it actually powers — and a sandbox credential from the + * Salesforce block entirely. * * Prefer this over comparing `getServiceConfigByProviderId(id)?.providerId` * against a service: that resolver walks `OAUTH_PROVIDERS` in declaration @@ -587,10 +593,51 @@ export function credentialProviderMatchesService( ): boolean { return ( service.providerId === credentialProviderId || - service.serviceAccountProviderId === credentialProviderId + service.serviceAccountProviderId === credentialProviderId || + (service.additionalProviderIds?.includes(credentialProviderId) ?? false) ) } +/** + * Every OAuth provider id whose credentials authenticate the service that + * `providerId` names — the id itself plus any `additionalProviderIds`. + * + * The SQL counterpart to {@link credentialProviderMatchesService}: list + * endpoints filter `account.providerId` / `credential.providerId` with + * `inArray(...)` on this, so the query and the predicate can't disagree and + * hide a credential the rest of the app considers usable. + * + * Widens only when `providerId` IS the service's primary OAuth id. Passing a + * service-account id or an alternate server's id returns just that id, so a + * query scoped to one credential family never broadens into another. + */ +export function providerIdsForService(providerId: string): string[] { + const service = getServiceConfigByProviderId(providerId) + if (!service || service.providerId !== providerId || !service.additionalProviderIds?.length) { + return [providerId] + } + return [providerId, ...service.additionalProviderIds] +} + +/** + * Folds an alternate authorization server's provider id back onto the service + * it belongs to (`salesforce-sandbox` → `salesforce`), leaving every other id + * untouched. The inverse of {@link providerIdsForService}. + * + * Deliberately narrower than {@link credentialProviderMatchesService}: a + * service-account id is shared by a whole family (one `google-service-account` + * matches Gmail, Drive, Sheets…), so folding it onto the first matching + * service would arbitrarily single out one product as connected. + */ +export function canonicalizeServiceProviderId( + credentialProviderId: string, + service: ServiceProviderIdentity | undefined +): string { + return service?.additionalProviderIds?.includes(credentialProviderId) + ? service.providerId + : credentialProviderId +} + export function getCanonicalScopesForProvider(providerId: string): string[] { const service = getServiceConfigByProviderId(providerId) return service?.scopes ? [...service.scopes] : [] @@ -670,6 +717,19 @@ for (const [baseProviderId, providerConfig] of Object.entries(OAUTH_PROVIDERS)) serviceKey, } } + // A second authorization server for the same service (`salesforce-sandbox`) + // maps to the same base and service key, so its credentials resolve the + // same icon and name. Without this the hyphen split would answer + // `{ base: 'salesforce', feature: 'sandbox' }` — a service that does not + // exist. + for (const extraProviderId of service.additionalProviderIds ?? []) { + if (!PROVIDER_ID_TO_BASE_PROVIDER[extraProviderId]) { + PROVIDER_ID_TO_BASE_PROVIDER[extraProviderId] = { + baseProvider: baseProviderId, + serviceKey, + } + } + } } } diff --git a/apps/sim/tools/salesforce/utils.test.ts b/apps/sim/tools/salesforce/utils.test.ts new file mode 100644 index 00000000000..76b6c81df84 --- /dev/null +++ b/apps/sim/tools/salesforce/utils.test.ts @@ -0,0 +1,70 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { getInstanceUrl } from '@/tools/salesforce/utils' + +/** Builds an unsigned JWT carrying the given payload, which is all the decoder reads. */ +function idTokenWith(payload: Record): string { + const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url') + return `${encode({ alg: 'none' })}.${encode(payload)}.sig` +} + +describe('getInstanceUrl', () => { + const ORG = 'https://acme.my.salesforce.com' + const SANDBOX_ORG = 'https://acme--sbx.sandbox.my.salesforce.com' + + it('prefers an explicitly provided instance URL', () => { + expect(getInstanceUrl(idTokenWith({ profile: `${ORG}/profile` }), SANDBOX_ORG)).toBe( + SANDBOX_ORG + ) + }) + + it('reads the org host from the profile claim', () => { + expect(getInstanceUrl(idTokenWith({ profile: `${ORG}/00530000009M943` }))).toBe(ORG) + }) + + it('reads the org host from the sub claim', () => { + expect(getInstanceUrl(idTokenWith({ sub: `${SANDBOX_ORG}/id/00D/005` }))).toBe(SANDBOX_ORG) + }) + + // `sub` on a Salesforce id token is rooted at the *authorization server* when + // no org can be resolved. Calling /services/data against a login host always + // fails, so neither host may be mistaken for an instance URL — test.salesforce.com + // included, or every sandbox credential would call the wrong host. + it.each([ + ['production login host', 'https://login.salesforce.com'], + ['sandbox login host', 'https://test.salesforce.com'], + ])('rejects the %s in the sub claim', (_label, loginOrigin) => { + expect(() => getInstanceUrl(idTokenWith({ sub: `${loginOrigin}/id/00D/005` }))).toThrow( + 'Salesforce instance URL is required' + ) + }) + + it.each([ + ['production login host', 'https://login.salesforce.com'], + ['sandbox login host', 'https://test.salesforce.com'], + ])('rejects the %s in the profile claim', (_label, loginOrigin) => { + expect(() => + getInstanceUrl(idTokenWith({ profile: `${loginOrigin}/00530000009M943` })) + ).toThrow('Salesforce instance URL is required') + }) + + it('falls through to sub when profile is rooted at a login host', () => { + // `profile` is normally org-rooted and `sub` login-rooted, but the reverse + // occurs; an `else if` here would abandon the lookup on the first claim. + expect( + getInstanceUrl( + idTokenWith({ + profile: 'https://test.salesforce.com/005', + sub: `${SANDBOX_ORG}/id/00D/005`, + }) + ) + ).toBe(SANDBOX_ORG) + }) + + it('throws when neither an instance URL nor a decodable token is available', () => { + expect(() => getInstanceUrl()).toThrow('Salesforce instance URL is required') + expect(() => getInstanceUrl('not-a-jwt')).toThrow('Salesforce instance URL is required') + }) +}) diff --git a/apps/sim/tools/salesforce/utils.ts b/apps/sim/tools/salesforce/utils.ts index 56aaf56f345..1da0c259c05 100644 --- a/apps/sim/tools/salesforce/utils.ts +++ b/apps/sim/tools/salesforce/utils.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { isSalesforceLoginOrigin } from '@/lib/oauth/salesforce' const logger = createLogger('SalesforceUtils') @@ -22,12 +23,24 @@ export function getInstanceUrl(idToken?: string, instanceUrl?: string): string { .join('') ) const decoded = JSON.parse(jsonPayload) - if (decoded.profile) { - const match = decoded.profile.match(/^(https:\/\/[^/]+)/) - if (match) return match[1] - } else if (decoded.sub) { - const match = decoded.sub.match(/^(https:\/\/[^/]+)/) - if (match && match[1] !== 'https://login.salesforce.com') return match[1] + // Both claims are rooted at the *authorization server* when no org host + // could be resolved, and `/services/data/...` against a login host always + // fails — so each is guarded, and `profile` falling through must still let + // `sub` be tried rather than short-circuiting the whole lookup. + for (const claim of [decoded.profile, decoded.sub]) { + if (typeof claim !== 'string') continue + // `URL` rather than a hand-rolled prefix regex: it normalizes away + // userinfo, default ports, and case, so the origin compared against the + // login-host set is the same one a fetch would actually use. + let origin: string + try { + const url = new URL(claim) + if (url.protocol !== 'https:') continue + origin = url.origin + } catch { + continue + } + if (!isSalesforceLoginOrigin(origin)) return origin } } catch (error) { logger.error('Failed to decode Salesforce idToken', { error })