From 97afdc5745b26875b6f4ab7eac2a3afd88a37498 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 10 Aug 2026 17:00:56 -0700 Subject: [PATCH 1/8] feat(salesforce): add JWT bearer flow and sandbox OAuth support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Salesforce integration users could only authenticate through interactive OAuth, which an API-only integration user cannot complete — there is no UI for them to log in to. Adds the JWT Bearer Flow as a second grant on the existing service-account provider, and registers sandbox as its own authorization server so sandbox orgs can connect at all. The assertion is audienced at the org's My Domain URL rather than login/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. My Domain is valid for Connected Apps and External Client Apps, production and sandbox alike, and is what the Salesforce CLI recommends — so the stored host alone determines the environment. Sandbox credentials are stored under their own provider id, mapped back to the one Salesforce service via additionalProviderIds on OAuthServiceConfig. That is threaded through every resolution point, including the two SQL filters that would otherwise have hidden sandbox credentials from the block picker entirely. Also fixes three latent bugs surfaced along the way: Zoom interpolated an undefined client secret into its Basic auth header, sandbox refresh tokens would have been posted to the production endpoint, and the sandbox connector would have been silently dropped as unconfigured. --- .../salesforce-service-account.mdx | 73 ++++- .../app/api/auth/oauth/credentials/route.ts | 5 +- .../app/api/auth/oauth/token/route.test.ts | 61 ++++ apps/sim/app/api/auth/oauth/token/route.ts | 27 +- apps/sim/app/api/auth/oauth/utils.ts | 6 +- apps/sim/app/api/credentials/[id]/route.ts | 3 + apps/sim/app/api/credentials/route.ts | 10 +- .../connect-oauth-modal.tsx | 40 ++- .../client-credential-account-modal.tsx | 262 ++++++++++-------- apps/sim/connectors/salesforce/salesforce.ts | 8 +- apps/sim/lib/api/contracts/credentials.ts | 19 +- apps/sim/lib/auth/auth.ts | 100 +++---- apps/sim/lib/auth/connectors/providers.ts | 104 ++++--- .../tools/server/user/get-credentials.test.ts | 13 + .../tools/server/user/get-credentials.ts | 12 +- apps/sim/lib/core/config/env-capabilities.ts | 3 + .../client-credential-accounts/descriptors.ts | 170 +++++++++++- .../client-credential-accounts/minters/box.ts | 4 +- .../minters/salesforce.test.ts | 169 +++++++++++ .../minters/salesforce.ts | 218 +++++++++++++-- .../minters/zoho-desk.ts | 5 +- .../minters/zoom.ts | 4 +- .../client-credential-accounts/server.test.ts | 35 +++ .../client-credential-accounts/server.ts | 32 ++- .../lib/credentials/orchestration/index.ts | 41 ++- .../lib/credentials/service-account-fields.ts | 3 + .../lib/credentials/service-account-secret.ts | 56 ++-- .../token-service-accounts/errors.ts | 23 ++ .../lib/integrations/credential-display.ts | 10 +- .../credential-visibility.server.ts | 6 + apps/sim/lib/oauth/oauth.test.ts | 23 +- apps/sim/lib/oauth/oauth.ts | 31 ++- apps/sim/lib/oauth/salesforce.ts | 96 +++++++ apps/sim/lib/oauth/types.ts | 27 ++ apps/sim/lib/oauth/utils.test.ts | 27 ++ apps/sim/lib/oauth/utils.ts | 55 +++- apps/sim/tools/salesforce/utils.test.ts | 48 ++++ apps/sim/tools/salesforce/utils.ts | 3 +- 38 files changed, 1507 insertions(+), 325 deletions(-) create mode 100644 apps/sim/lib/oauth/salesforce.ts create mode 100644 apps/sim/tools/salesforce/utils.test.ts 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..a4088ce559f 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 @@ -105,6 +116,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. + + + + 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: @@ -137,12 +181,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 +205,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/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/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 && ( 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..0cb98ec4b0b 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,171 @@ 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: any; claims: any; 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('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) + expect(secondsAhead).toBeGreaterThan(0) + expect(secondsAhead).toBeLessThanOrEqual(300) + }) + + 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' }) + 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('maps an unapproved run-as user to a pre-authorization hint', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(400, { + error: 'invalid_grant', + error_description: "user hasn't approved this consumer", + }) + ) + + 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..1f30aeb9dd5 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 rejects an `exp` more than 5 minutes ahead of *its* clock, so a + * short window bounds replay while leaving room for clock skew between Sim and + * Salesforce. + */ +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,179 @@ 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'}`, + }) + } + 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. The My Domain URL is valid for both Connected Apps and + * External Client Apps, in production and in sandboxes, so it is the only + * audience that works across all four combinations — and it means the stored + * host alone determines the environment, with nothing left to infer. + * Salesforce's own CLI recommends the My Domain login URL for the same reason. + * + * @see https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_jwt_flow.htm&type=5 + */ +function buildSalesforceJwtAssertion( + consumerKey: string, + username: string, + host: string, + privateKey: KeyObject +): Promise { + return new SignJWT() + .setProtectedHeader({ alg: 'RS256', typ: 'JWT' }) + .setIssuer(consumerKey) + .setSubject(username) + .setAudience(`https://${host}`) + .setExpirationTime(Math.floor(Date.now() / 1000) + JWT_ASSERTION_LIFETIME_SECONDS) + .sign(privateKey) +} + +/** + * 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. + */ +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 (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('user not found') || description.includes('invalid username')) { + return 'the run-as username does not exist in this org, or is inactive' + } + 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 +393,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 +412,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/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index b36ca844047..17e99a5bea6 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -71,13 +71,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 +134,9 @@ export interface PerformUpdateCredentialParams extends CredentialActorParams { clientSecret?: string orgId?: string dataCenter?: string + authMethod?: string + privateKey?: string + username?: string } export interface PerformCredentialResult { @@ -190,7 +197,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 +212,10 @@ 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 + const needsStoredAuthMethod = params.authMethod === undefined && isClientCredentialProvider + const needsStoredUsername = params.username === undefined && isClientCredentialProvider // 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 +228,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 +242,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..8b83a610e8d --- /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..ff8c94af163 100644 --- a/apps/sim/lib/oauth/utils.test.ts +++ b/apps/sim/lib/oauth/utils.test.ts @@ -10,6 +10,7 @@ import { getServiceConfigByProviderId, getServiceConfigByServiceId, parseProvider, + providerIdsForService, } from './utils' describe('getAllOAuthServices', () => { @@ -721,3 +722,29 @@ 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']) + }) +}) diff --git a/apps/sim/lib/oauth/utils.ts b/apps/sim/lib/oauth/utils.ts index 21ccaeadbd8..d0d16fbd9f7 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,32 @@ 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] +} + export function getCanonicalScopesForProvider(providerId: string): string[] { const service = getServiceConfigByProviderId(providerId) return service?.scopes ? [...service.scopes] : [] @@ -670,6 +698,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..58d5e5713c6 --- /dev/null +++ b/apps/sim/tools/salesforce/utils.test.ts @@ -0,0 +1,48 @@ +/** + * @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('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..9360da53a00 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') @@ -27,7 +28,7 @@ export function getInstanceUrl(idToken?: string, instanceUrl?: string): string { 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] + if (match && !isSalesforceLoginOrigin(match[1])) return match[1] } } catch (error) { logger.error('Failed to decode Salesforce idToken', { error }) From 79e7242b574288f0779785c131649f954c8cb11a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 10 Aug 2026 17:12:33 -0700 Subject: [PATCH 2/8] fix(salesforce): canonicalize connected provider ids in the copilot credential tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A credential stored under an alternate authorization server was recorded in `connectedProviderIds` under its own id, while the not-connected list compares against the service's canonical id — so a sandbox-only Salesforce user was reported as both connected and not connected. Record the canonical id instead. Also types the JWT test's assertion decoder instead of returning `any`. --- .../tools/server/user/get-credentials.test.ts | 44 +++++++++++++++++++ .../tools/server/user/get-credentials.ts | 8 +++- .../minters/salesforce.test.ts | 6 ++- 3 files changed, 55 insertions(+), 3 deletions(-) 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 329106dfcc2..61765cbfb10 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 @@ -264,6 +264,50 @@ 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('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 d545b67db89..d2136f2569a 100644 --- a/apps/sim/lib/copilot/tools/server/user/get-credentials.ts +++ b/apps/sim/lib/copilot/tools/server/user/get-credentials.ts @@ -110,7 +110,11 @@ export const getCredentialsServerTool: BaseServerTool credentialProviderMatchesService(providerId, candidate) ) if (!credentialVisibility.isCredentialVisible({ providerId, type: 'oauth' })) continue - connectedProviderIds.add(providerId) + // The canonical id, not the credential's own: `notConnectedServices` below + // compares against `service.providerId`, so recording an alternate + // authorization server's id (`salesforce-sandbox`) verbatim would list the + // service as both connected and not connected. + connectedProviderIds.add(service?.providerId ?? providerId) const [baseProvider, featureType = 'default'] = providerId.split('-') let displayName = '' @@ -163,7 +167,7 @@ export const getCredentialsServerTool: BaseServerTool const service = allOAuthServices.find((candidate) => credentialProviderMatchesService(cred.providerId, candidate) ) - connectedProviderIds.add(cred.providerId) + connectedProviderIds.add(service?.providerId ?? cred.providerId) const [, featureType = 'default'] = cred.providerId.split('-') connectedCredentials.push({ id: cred.id, 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 0cb98ec4b0b..c3f4b097751 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 @@ -362,7 +362,11 @@ describe('mintSalesforceServiceAccountToken (JWT bearer)', () => { } /** Pulls the posted assertion apart and verifies its RS256 signature. */ - function readPostedAssertion(): { header: any; claims: any; verified: boolean } { + function readPostedAssertion(): { + header: { alg: string; typ: string } + claims: { aud: string; iss: string; sub: string; exp: number } + verified: boolean + } { const [url, init] = mockFetch.mock.calls[0] expect(url).toBe(TOKEN_URL) const body = new URLSearchParams(init.body as string) From a9334ed0c671cde78cd4ae4e0f7d927d01b67031 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 10 Aug 2026 17:36:23 -0700 Subject: [PATCH 3/8] fix(salesforce): close reconnect, instance-URL, and key-handling gaps An independent audit swarm found four real defects in the JWT bearer work: - The credential update hook rebuilt its request body from a hand-written allowlist, so `authMethod`, `privateKey`, and `username` were silently dropped. A JWT private key could never be rotated through the UI, and switching grants failed with a generic error. Forwards the whole contract body instead, so a field added to the contract later cannot be lost again. - `getInstanceUrl` guarded only the `sub` claim against login-host origins, so a sandbox id token whose `profile` was rooted at test.salesforce.com yielded the login host as the org's API base. Both claims are now guarded, and a guarded-away `profile` falls through to `sub` instead of ending the lookup. - `canonicalizeServiceProviderId` replaces the previous fold, which also matched family-wide service-account ids and so dropped one arbitrary sibling product (Gmail, Confluence) from the copilot's not-connected list. - The private key was collected in a plain textarea, leaving browser spell check and autofill free to ship it to third parties. Also restores the explicit https check on the userinfo-derived instance URL, anchors the scope marker, caps the accepted RSA modulus, and stops single-grant providers paying for a stored-blob decrypt on every reconnect. Docs: the JWT path no longer tells readers to enable the Client Credentials Flow, and calls out the my.salesforce-setup.com host as the likely wrong paste. Adds coverage for the paths the audit proved untested: partitionClientCredentialFields, credentialProviderMatchesService's alternate-server clause, reconnect carry-forward, the typographic-apostrophe error branch, and the passphrase hint. --- .../salesforce-service-account.mdx | 13 ++- .../client-credential-account-modal.tsx | 41 +++++-- apps/sim/hooks/queries/credentials.ts | 20 +--- apps/sim/lib/auth/auth.ts | 7 +- .../tools/server/user/get-credentials.test.ts | 57 ++++++++++ .../tools/server/user/get-credentials.ts | 17 +-- .../descriptors.test.ts | 104 ++++++++++++++++++ .../client-credential-accounts/descriptors.ts | 2 +- .../minters/salesforce.test.ts | 25 +++-- .../minters/salesforce.ts | 10 ++ .../credentials/orchestration/index.test.ts | 68 ++++++++++++ .../lib/credentials/orchestration/index.ts | 14 ++- apps/sim/lib/oauth/salesforce.ts | 2 +- apps/sim/lib/oauth/utils.test.ts | 46 ++++++++ apps/sim/lib/oauth/utils.ts | 19 ++++ apps/sim/tools/salesforce/utils.test.ts | 22 ++++ apps/sim/tools/salesforce/utils.ts | 14 ++- 17 files changed, 425 insertions(+), 56 deletions(-) create mode 100644 apps/sim/lib/credentials/client-credential-accounts/descriptors.test.ts 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 a4088ce559f..6ff28bf72b1 100644 --- a/apps/docs/content/docs/en/integrations/salesforce-service-account.mdx +++ b/apps/docs/content/docs/en/integrations/salesforce-service-account.mdx @@ -65,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 */} @@ -118,7 +121,7 @@ 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. +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. @@ -157,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 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 10295e0efc0..b214ce0b076 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,6 +8,7 @@ import { ChipModalField, ChipModalFooter, ChipModalHeader, + ChipTextarea, SecretInput, } from '@sim/emcn' import { createLogger } from '@sim/logger' @@ -138,8 +139,10 @@ export function ClientCredentialAccountModal({ const visibleFields = mustRestateAuthMethod ? visible.filter((field) => !field.requiredForAuthMethods) : visible - const requiredFields = mustRestateAuthMethod && authMethodField ? [authMethodField] : required - const requiredFieldIds = new Set(requiredFields.map((field) => field.id)) + // Markers always reflect the descriptor's real requirements, so `clientId` + // and the host don't lose their asterisk while the method is unset. Submit is + // gated separately below — picking a method is what unblocks it. + const requiredFieldIds = new Set(required.map((field) => 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. @@ -147,7 +150,8 @@ export function ClientCredentialAccountModal({ const displayedAuthMethod = mustRestateAuthMethod ? undefined : (values.authMethod ?? descriptor.defaultAuthMethod) - const missingRequired = requiredFields.some((field) => !values[field.id]?.trim()) + const missingRequired = + mustRestateAuthMethod || required.some((field) => !values[field.id]?.trim()) const isPending = createCredential.isPending || updateCredential.isPending const isDisabled = missingRequired || isPending @@ -197,7 +201,7 @@ export function ClientCredentialAccountModal({ if (connectedCredentialId) onCreated?.(connectedCredentialId) onOpenChange(false) } catch (err: unknown) { - setError(messageForClientCredentialError(err, descriptor, requiredFields)) + setError(messageForClientCredentialError(err, descriptor, required)) logger.error(`Failed to add ${descriptor.serviceLabel} service account credential`, err) } } @@ -247,16 +251,31 @@ export function ClientCredentialAccountModal({ return ( setField(field.id, next)} - placeholder={field.placeholder} required={required} - minHeight={120} - mono hint={hint} - /> + > + {(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' + /> + )} + ) } 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/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 96d8bc9d333..5717d00db96 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -185,8 +185,11 @@ async function fetchSalesforceInstanceUrl( if (!response.ok) return undefined const data = await response.json() if (typeof data.profile !== 'string') return undefined - const origin = new URL(data.profile).origin - return isSalesforceLoginOrigin(origin) ? undefined : origin + 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 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 61765cbfb10..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,15 @@ 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: ( @@ -308,6 +317,54 @@ describe('getCredentialsServerTool', () => { ).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 d2136f2569a..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 { credentialProviderMatchesService, 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' @@ -110,11 +114,10 @@ export const getCredentialsServerTool: BaseServerTool credentialProviderMatchesService(providerId, candidate) ) if (!credentialVisibility.isCredentialVisible({ providerId, type: 'oauth' })) continue - // The canonical id, not the credential's own: `notConnectedServices` below - // compares against `service.providerId`, so recording an alternate - // authorization server's id (`salesforce-sandbox`) verbatim would list the - // service as both connected and not connected. - connectedProviderIds.add(service?.providerId ?? 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 = '' @@ -167,7 +170,7 @@ export const getCredentialsServerTool: BaseServerTool const service = allOAuthServices.find((candidate) => credentialProviderMatchesService(cred.providerId, candidate) ) - connectedProviderIds.add(service?.providerId ?? 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/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 15fa8cf1bd7..78bbd0af247 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts @@ -401,7 +401,7 @@ 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, 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 c3f4b097751..d4dfbedc48b 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 @@ -431,8 +431,10 @@ describe('mintSalesforceServiceAccountToken (JWT bearer)', () => { const { claims } = readPostedAssertion() const secondsAhead = claims.exp - Math.floor(Date.now() / 1000) - expect(secondsAhead).toBeGreaterThan(0) - expect(secondsAhead).toBeLessThanOrEqual(300) + // 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 () => { @@ -457,7 +459,12 @@ describe('mintSalesforceServiceAccountToken (JWT bearer)', () => { await expect( mintSalesforceServiceAccountToken({ ...JWT_FIELDS, privateKey: encrypted }) - ).rejects.toMatchObject({ code: 'invalid_credentials' }) + ).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() }) @@ -500,12 +507,14 @@ describe('mintSalesforceServiceAccountToken (JWT bearer)', () => { }) }) - it('maps an unapproved run-as user to a pre-authorization hint', async () => { + 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: "user hasn't approved this consumer", - }) + jsonResponse(400, { error: 'invalid_grant', error_description: description }) ) await expect(mintSalesforceServiceAccountToken(JWT_FIELDS)).rejects.toMatchObject({ 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 1f30aeb9dd5..6a744ab3756 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts @@ -227,6 +227,16 @@ function loadSalesforcePrivateKey(privateKeyPem: string): KeyObject { 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 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 17e99a5bea6..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 { @@ -214,8 +217,13 @@ export async function performUpdateCredential( // when the caller did not supply one. const isClientCredentialProvider = isClientCredentialAccountProviderId(providerId) const needsStoredDataCenter = params.dataCenter === undefined && isClientCredentialProvider - const needsStoredAuthMethod = params.authMethod === undefined && isClientCredentialProvider - const needsStoredUsername = params.username === 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) diff --git a/apps/sim/lib/oauth/salesforce.ts b/apps/sim/lib/oauth/salesforce.ts index 8b83a610e8d..d5890802f3d 100644 --- a/apps/sim/lib/oauth/salesforce.ts +++ b/apps/sim/lib/oauth/salesforce.ts @@ -66,7 +66,7 @@ export function isSalesforceOAuthProviderId(providerId: string | null | undefine */ const SALESFORCE_INSTANCE_SCOPE_PREFIX = '__sf_instance__:' -const SALESFORCE_INSTANCE_URL_REGEX = new RegExp(`${SALESFORCE_INSTANCE_SCOPE_PREFIX}([^\\s]+)`) +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( diff --git a/apps/sim/lib/oauth/utils.test.ts b/apps/sim/lib/oauth/utils.test.ts index ff8c94af163..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, @@ -748,3 +751,46 @@ describe('providerIdsForService', () => { 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 d0d16fbd9f7..eef2b12d759 100644 --- a/apps/sim/lib/oauth/utils.ts +++ b/apps/sim/lib/oauth/utils.ts @@ -619,6 +619,25 @@ export function providerIdsForService(providerId: string): string[] { 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] : [] diff --git a/apps/sim/tools/salesforce/utils.test.ts b/apps/sim/tools/salesforce/utils.test.ts index 58d5e5713c6..76b6c81df84 100644 --- a/apps/sim/tools/salesforce/utils.test.ts +++ b/apps/sim/tools/salesforce/utils.test.ts @@ -41,6 +41,28 @@ describe('getInstanceUrl', () => { ) }) + 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 9360da53a00..e2c2ec99da2 100644 --- a/apps/sim/tools/salesforce/utils.ts +++ b/apps/sim/tools/salesforce/utils.ts @@ -23,12 +23,14 @@ 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 && !isSalesforceLoginOrigin(match[1])) 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 + const origin = claim.match(/^(https:\/\/[^/]+)/)?.[1] + if (origin && !isSalesforceLoginOrigin(origin)) return origin } } catch (error) { logger.error('Failed to decode Salesforce idToken', { error }) From 0af266fa1bb3c3c400b57a23d6c28723727adbb6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 10 Aug 2026 17:42:56 -0700 Subject: [PATCH 4/8] fix(salesforce): handle the Government Cloud JWT audience and unassigned-profile errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verification against Salesforce's own sfdx-core surfaced two gaps: - `gs1` Government Cloud orgs have ordinary *.my.salesforce.com hosts, but Salesforce requires `https://gs1.salesforce.com` as the JWT audience. The host regex accepted them, so they would have failed with an opaque audience error. The token still posts to the org's own host; only `aud` differs. - `invalid_app_access` — Permitted Users is set to admin-pre-authorized but the run-as user's profile was never assigned to the app — is the likeliest misconfiguration and had no hint at all. Also sends `iat`, matching sfdx-core and every mainstream implementation, and softens two TSDoc claims that were stronger than the evidence: Salesforce does not hard-reject a far-future `exp` (its own CLI ships one), and My Domain is the right audience for commercial orgs rather than universally. --- .../minters/salesforce.test.ts | 45 +++++++++++++++- .../minters/salesforce.ts | 51 +++++++++++++------ 2 files changed, 79 insertions(+), 17 deletions(-) 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 d4dfbedc48b..f0fa951fc37 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 @@ -364,7 +364,7 @@ describe('mintSalesforceServiceAccountToken (JWT bearer)', () => { /** 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 } + claims: { aud: string; iss: string; sub: string; exp: number; iat: number } verified: boolean } { const [url, init] = mockFetch.mock.calls[0] @@ -422,6 +422,49 @@ describe('mintSalesforceServiceAccountToken (JWT bearer)', () => { 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-acme.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-acme.my.salesforce.com/services/oauth2/token') + }) + + 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' })) 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 6a744ab3756..474e11132f4 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts @@ -36,10 +36,10 @@ 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 rejects an `exp` more than 5 minutes ahead of *its* clock, so a - * short window bounds replay while leaving room for clock skew between Sim and - * Salesforce. + * 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 @@ -254,13 +254,17 @@ function loadSalesforcePrivateKey(privateKeyPem: string): KeyObject { * `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. The My Domain URL is valid for both Connected Apps and - * External Client Apps, in production and in sandboxes, so it is the only - * audience that works across all four combinations — and it means the stored - * host alone determines the environment, with nothing left to infer. - * Salesforce's own CLI recommends the My Domain login URL for the same reason. + * 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, @@ -268,13 +272,25 @@ function buildSalesforceJwtAssertion( host: string, privateKey: KeyObject ): Promise { - return new SignJWT() - .setProtectedHeader({ alg: 'RS256', typ: 'JWT' }) - .setIssuer(consumerKey) - .setSubject(username) - .setAudience(`https://${host}`) - .setExpirationTime(Math.floor(Date.now() / 1000) + JWT_ASSERTION_LIFETIME_SECONDS) - .sign(privateKey) + 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. */ +function salesforceJwtAudience(host: string): string { + return host.startsWith('gs1.') || host.startsWith('gs1-') + ? 'https://gs1.salesforce.com' + : `https://${host}` } /** @@ -290,6 +306,9 @@ function salesforceJwtErrorHint(body: string): string | undefined { 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' } From 3b04abc88d13c9dddfa1720541f20d210bd4b034 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 10 Aug 2026 18:00:43 -0700 Subject: [PATCH 5/8] fix(salesforce): match sandbox credentials in Chat and the connect draft Two more surfaces resolved a credential to its service by exact provider id: - `credentialsForTarget` compared only `providerId`/`baseProviderId`, so a sandbox-only user's Salesforce chip in Chat read as disconnected and re-prompted them to connect. The alternate ids are passed in by the caller rather than resolved in the module, which is `'use client'` and would otherwise pull the OAuth provider registry into the chat bundle. - `createConnectDraft` resolved the service name by exact id, so a sandbox connect defaulted to the label "My salesforce-sandbox". --- .../api/auth/oauth2/authorize/route.test.ts | 13 +++++ .../special-tags/use-oauth-chip-connection.ts | 12 +++- apps/sim/lib/credentials/connect-draft.ts | 9 ++- .../credentials/oauth-chat-attempt.test.ts | 57 +++++++++++++++++++ .../sim/lib/credentials/oauth-chat-attempt.ts | 12 +++- 5 files changed, 98 insertions(+), 5 deletions(-) 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/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts index f8933527f8c..3712d9657ea 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts @@ -22,7 +22,7 @@ import { } from '@/lib/credentials/oauth-chat-attempt' import { getDesktopBridge } from '@/lib/desktop' import type { OAuthProvider } from '@/lib/oauth/types' -import { parseProvider } from '@/lib/oauth/utils' +import { parseProvider, providerIdsForService } from '@/lib/oauth/utils' import { useWorkspaceCredentials } from '@/hooks/queries/credentials' const OAUTH_POPUP_WINDOW_NAME = 'sim-oauth-connect' @@ -188,7 +188,15 @@ export function useOAuthChipConnection({ } | null>(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 ?? ''}` 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..483a24aa7d7 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,59 @@ 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) + }) +}) diff --git a/apps/sim/lib/credentials/oauth-chat-attempt.ts b/apps/sim/lib/credentials/oauth-chat-attempt.ts index 0a6fd6bed3e..939c87a8886 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 { @@ -65,7 +72,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)) ) } From d79c22db223cf5947b353b98aca7f0a77c56d1a1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 10 Aug 2026 18:16:32 -0700 Subject: [PATCH 6/8] fix(salesforce): carry alternate provider ids through chat connect verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chip's live target was widened to match a sandbox credential, but the post-connect verification leg re-reads the STORED attempt, which did not carry the ids — so completing a sandbox connect from Chat was detected as a failure and the chip was marked failed. The attempt now persists them; attempts written before this simply match as they did, and they expire within 15 minutes. Also marks the auth-method picker required while it is the field blocking submit on a reconnect, so the greyed button has a visible cause. --- .../special-tags/use-oauth-chip-connection.ts | 1 + .../client-credential-account-modal.tsx | 8 +++-- .../credentials/oauth-chat-attempt.test.ts | 29 +++++++++++++++++++ .../sim/lib/credentials/oauth-chat-attempt.ts | 11 +++++++ 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts index 3712d9657ea..cfe965c603d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts @@ -479,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 b214ce0b076..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 @@ -139,10 +139,12 @@ export function ClientCredentialAccountModal({ const visibleFields = mustRestateAuthMethod ? visible.filter((field) => !field.requiredForAuthMethods) : visible - // Markers always reflect the descriptor's real requirements, so `clientId` - // and the host don't lose their asterisk while the method is unset. Submit is - // gated separately below — picking a method is what unblocks it. + // 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. diff --git a/apps/sim/lib/credentials/oauth-chat-attempt.test.ts b/apps/sim/lib/credentials/oauth-chat-attempt.test.ts index 483a24aa7d7..5a1b97e0126 100644 --- a/apps/sim/lib/credentials/oauth-chat-attempt.test.ts +++ b/apps/sim/lib/credentials/oauth-chat-attempt.test.ts @@ -311,3 +311,32 @@ describe('credential matching for a chat chip', () => { ).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 939c87a8886..25bad7ff804 100644 --- a/apps/sim/lib/credentials/oauth-chat-attempt.ts +++ b/apps/sim/lib/credentials/oauth-chat-attempt.ts @@ -43,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 @@ -56,6 +65,8 @@ interface CreateOAuthChatAttemptInput { workspaceId: string providerId: string baseProviderId: string + /** See {@link OAuthCredentialTarget.additionalProviderIds}. */ + additionalProviderIds?: readonly string[] displayName: string controlId: string credentialId?: string From ea28d92ffb0942800e23c703f1700cd741dcb36e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 10 Aug 2026 18:23:37 -0700 Subject: [PATCH 7/8] fix(salesforce): send reauthorize to the server that issued the credential MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Update access" derived its provider from the service id, which always yields the primary authorization server. A sandbox credential missing a scope sent the user to login.salesforce.com — where a sandbox-only user cannot sign in at all, and where a user who can sign in creates an orphan production account while the banner never clears. Both credential selectors now pass the selected credential's own provider id, which the connect modal already honours. Also names the alternate provider ids explicitly in the disconnect sweep. That branch is unreachable today (every caller sends an accountId), but it was catching them only by the `{base}-` prefix accident. --- apps/sim/app/api/auth/oauth/disconnect/route.ts | 9 ++++++++- .../credential-selector/credential-selector.tsx | 4 ++++ .../tool-input/components/tools/credential-selector.tsx | 4 ++++ 3 files changed, 16 insertions(+), 1 deletion(-) 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/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx index 43c818d3b86..bd1cd4d6114 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx @@ -508,6 +508,10 @@ export function CredentialSelector({ 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/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} /> )} From 2fc618a700f81f2cebf563d45fa2bd9861c30cab Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 10 Aug 2026 18:32:52 -0700 Subject: [PATCH 8/8] fix(salesforce): make the Government Cloud audience check exact, not a prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `startsWith('gs1-')` was invented from a paraphrase of sfdx-core and would have misrouted an ordinary org like gs1-widgets.my.salesforce.com to the GovCloud audience — breaking a setup that works today. sfdx-core's host signal is the literal gs1.my.salesforce.com; its other signal is the org's createdOrgInstance, which we never see. Matching exactly means a miss falls back to My Domain, which is the behaviour before the branch existed, while a false positive cannot happen. Also replaces the hand-rolled origin regex in getInstanceUrl with URL parsing, which normalizes userinfo, ports, and case before the login-host comparison, and drops two error hints that had no evidence behind them. --- .../minters/salesforce.test.ts | 23 +++++++++++++-- .../minters/salesforce.ts | 28 ++++++++++++++----- apps/sim/tools/salesforce/utils.ts | 14 ++++++++-- 3 files changed, 54 insertions(+), 11 deletions(-) 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 f0fa951fc37..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 @@ -429,14 +429,33 @@ describe('mintSalesforceServiceAccountToken (JWT bearer)', () => { .mockResolvedValueOnce(jsonResponse(200, { access_token: 'sf-jwt-token' })) .mockResolvedValueOnce(jsonResponse(403, {})) - await mintSalesforceServiceAccountToken({ ...JWT_FIELDS, orgId: 'gs1-acme.my.salesforce.com' }) + 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-acme.my.salesforce.com/services/oauth2/token') + 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 () => { 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 474e11132f4..495dbb48310 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts @@ -286,11 +286,23 @@ function buildSalesforceJwtAssertion( ) } -/** Government Cloud orgs authenticate at a dedicated audience; everyone else uses My Domain. */ +/** + * 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 { - return host.startsWith('gs1.') || host.startsWith('gs1-') - ? 'https://gs1.salesforce.com' - : `https://${host}` + const isGovCloud = + host === SALESFORCE_GOV_CLOUD_HOST || host.endsWith(`.${SALESFORCE_GOV_CLOUD_HOST}`) + return isGovCloud ? 'https://gs1.salesforce.com' : `https://${host}` } /** @@ -298,6 +310,11 @@ function salesforceJwtAudience(host: string): string { * 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 { @@ -318,9 +335,6 @@ function salesforceJwtErrorHint(body: string): string | undefined { 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('user not found') || description.includes('invalid username')) { - return 'the run-as username does not exist in this org, or is inactive' - } if (description.includes('client identifier') || parsed.error === 'invalid_client_id') { return 'the consumer key is invalid for this org' } diff --git a/apps/sim/tools/salesforce/utils.ts b/apps/sim/tools/salesforce/utils.ts index e2c2ec99da2..1da0c259c05 100644 --- a/apps/sim/tools/salesforce/utils.ts +++ b/apps/sim/tools/salesforce/utils.ts @@ -29,8 +29,18 @@ export function getInstanceUrl(idToken?: string, instanceUrl?: string): string { // `sub` be tried rather than short-circuiting the whole lookup. for (const claim of [decoded.profile, decoded.sub]) { if (typeof claim !== 'string') continue - const origin = claim.match(/^(https:\/\/[^/]+)/)?.[1] - if (origin && !isSalesforceLoginOrigin(origin)) return origin + // `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 })