Skip to content

feat(salesforce): add JWT bearer flow and sandbox OAuth support - #6508

Merged
waleedlatif1 merged 8 commits into
stagingfrom
salesforce-auth-methods
Aug 11, 2026
Merged

feat(salesforce): add JWT bearer flow and sandbox OAuth support#6508
waleedlatif1 merged 8 commits into
stagingfrom
salesforce-auth-methods

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

  • Add the OAuth 2.0 JWT Bearer Flow as a second grant on the Salesforce service-account credential, selected by an auth-method picker in the connect modal. Client credentials stays the default, so existing credentials are untouched.
  • Add sandbox user OAuth. Sandbox orgs authenticate at test.salesforce.com and simply cannot log in at login.salesforce.com, so they get their own Better Auth provider registration, mapped back to the one Salesforce service via a new additionalProviderIds on OAuthServiceConfig.
  • Both unblock API-only integration users, who cannot complete interactive OAuth because they have no UI to log in to. That was the only supported method before this PR.

Why the assertion is audienced at My Domain

Salesforce ended legacy hostname redirections in Spring '26, and External Client Apps now reject test.salesforce.com as a JWT audience with app_not_found. The My Domain URL is valid for Connected Apps and External Client Apps, production and sandbox alike, and is what the Salesforce CLI recommends — so aud and the token endpoint are both the org's My Domain host, and the stored host alone determines the environment.

Latent bugs fixed along the way

  • Zoom interpolated an undefined client secret into its Basic auth header (template string, so tsc could not catch it).
  • Sandbox refresh tokens would have been posted to the production endpoint — getBaseProviderForService collapsed the sandbox id to its base provider.
  • The sandbox connector would have been silently dropped as unconfigured by buildConnectorProviders.
  • Two SQL filters (/api/auth/oauth/credentials, /api/credentials) matched providerId exactly, which would have hidden every sandbox credential from the block picker no matter how correct the in-memory resolvers were.

Type of Change

  • New feature

Testing

  • 21,316 tests pass; type-check, lint:check, and the check:* gates are green.
  • New coverage: the JWT assertion is signed with a real generated RSA keypair and its RS256 signature verified against the public key, plus aud/iss/sub/exp claims, both PEM containers (PKCS#1 and PKCS#8), passphrase-protected and malformed key rejection, the SQL widening, both instance-URL paths, and backwards compatibility of pre-JWT credential blobs.
  • Each new test was confirmed to fail when its fix is reverted.
  • Not yet exercised against a live Salesforce org — no assertion has actually been exchanged with Salesforce. Worth a smoke test against one org before announcing.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview Aug 11, 2026 1:47am

Request Review

@cursor

cursor Bot commented Aug 11, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Changes OAuth token minting, credential storage (encrypted private keys), and multi-surface provider-id matching for a critical CRM integration; misconfiguration or resolver drift could hide credentials or mint against the wrong grant or auth server.

Overview
Adds Salesforce JWT bearer as a second server-to-server grant on integration-user credentials, alongside existing client credentials. The connect modal picks the method and only collects that branch’s fields (secret vs PEM private key + run-as username); the minter signs an RS256 assertion with My Domain as aud, and reconnect/rotation keeps stored authMethod and username when only the key is retyped.

Introduces sandbox interactive OAuth via a separate salesforce-sandbox connector (test.salesforce.com), wired back to one Salesforce service through additionalProviderIds, providerIdsForService, and credentialProviderMatchesService so list/disconnect/chat/copilot surfaces treat sandbox and production credentials as the same integration. Connect OAuth shows an Environment picker; reauthorize passes the credential’s stored providerId so sandbox users aren’t sent to production. Token refresh and instance URL handling use shared lib/oauth/salesforce helpers for both provider ids.

Docs expand the integration-user guide for JWT setup, FAQ, and My Domain warnings. Hardening: requireClientSecret on single-grant minters; credential update mutation forwards full contract body; orchestration preserves multi-grant blob fields on rotation.

Reviewed by Cursor Bugbot for commit 2fc618a. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds Salesforce JWT bearer service-account authentication and sandbox OAuth support while preserving client credentials as the default.

  • Registers separate production and sandbox Salesforce authorization servers under one canonical service.
  • Propagates alternate provider IDs through credential discovery, filtering, connection state, refresh, and disconnect flows.
  • Adds descriptor-driven JWT credential fields, encrypted key storage, assertion signing, validation, and focused regression coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts Adds typed JWT bearer assertion construction, RSA key validation, Salesforce-specific audience selection, and grant-aware token requests.
apps/sim/lib/oauth/utils.ts Centralizes canonical matching between Salesforce’s primary, sandbox, and service-account provider identifiers.
apps/sim/lib/auth/connectors/providers.ts Registers production and sandbox Salesforce OAuth connectors against their respective login hosts.
apps/sim/lib/copilot/tools/server/user/get-credentials.ts Canonicalizes alternate Salesforce provider IDs so sandbox credentials correctly mark the Salesforce service connected.
apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.test.ts Uses explicitly typed decoded assertion fields and verifies JWT claims, signatures, key formats, audiences, and error mappings.

Sequence Diagram

sequenceDiagram
  participant User
  participant Sim as Sim credential flow
  participant SF as Salesforce My Domain
  User->>Sim: Select client credentials or JWT bearer
  alt Client credentials
    Sim->>SF: POST client_id + client_secret
  else JWT bearer
    Sim->>Sim: Sign RS256 assertion with private key
    Sim->>SF: POST JWT bearer assertion
  end
  SF-->>Sim: Access token + instance URL
  Sim-->>User: Store encrypted credential
Loading

Reviews (6): Last reviewed commit: "fix(salesforce): make the Government Clo..." | Re-trigger Greptile

Comment thread apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.test.ts Outdated
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/copilot/tools/server/user/get-credentials.ts Outdated
@waleedlatif1
waleedlatif1 force-pushed the salesforce-auth-methods branch from 9ea5b3a to 48ffd46 Compare August 11, 2026 00:43
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/copilot/tools/server/user/get-credentials.ts
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit ac16fcb. Configure here.

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

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.
…redential tool

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`.
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.
…ned-profile errors

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.
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".
…rification

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.
…ntial

"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.
…a prefix

`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.
@waleedlatif1
waleedlatif1 force-pushed the salesforce-auth-methods branch from bbaf32e to 2fc618a Compare August 11, 2026 01:42
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 2fc618a. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant