Skip to content

Commit 48ffd46

Browse files
committed
fix(salesforce): handle the Government Cloud JWT audience and unassigned-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.
1 parent 200d51b commit 48ffd46

2 files changed

Lines changed: 79 additions & 17 deletions

File tree

apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ describe('mintSalesforceServiceAccountToken (JWT bearer)', () => {
364364
/** Pulls the posted assertion apart and verifies its RS256 signature. */
365365
function readPostedAssertion(): {
366366
header: { alg: string; typ: string }
367-
claims: { aud: string; iss: string; sub: string; exp: number }
367+
claims: { aud: string; iss: string; sub: string; exp: number; iat: number }
368368
verified: boolean
369369
} {
370370
const [url, init] = mockFetch.mock.calls[0]
@@ -422,6 +422,49 @@ describe('mintSalesforceServiceAccountToken (JWT bearer)', () => {
422422
expect(claims.sub).toBe('integration.user@yourorg.com')
423423
})
424424

425+
it('audiences a Government Cloud org at gs1.salesforce.com, not its My Domain', async () => {
426+
// Salesforce's own sfdx-core substitutes this audience for gs1 orgs, whose
427+
// hosts are otherwise ordinary *.my.salesforce.com.
428+
mockFetch
429+
.mockResolvedValueOnce(jsonResponse(200, { access_token: 'sf-jwt-token' }))
430+
.mockResolvedValueOnce(jsonResponse(403, {}))
431+
432+
await mintSalesforceServiceAccountToken({ ...JWT_FIELDS, orgId: 'gs1-acme.my.salesforce.com' })
433+
434+
const [url, init] = mockFetch.mock.calls[0]
435+
const assertion = new URLSearchParams(init.body as string).get('assertion') as string
436+
const claims = JSON.parse(Buffer.from(assertion.split('.')[1], 'base64url').toString())
437+
expect(claims.aud).toBe('https://gs1.salesforce.com')
438+
// The token still POSTs to the org's own host — only the audience differs.
439+
expect(url).toBe('https://gs1-acme.my.salesforce.com/services/oauth2/token')
440+
})
441+
442+
it('carries an iat claim, matching every mainstream Salesforce implementation', async () => {
443+
mockFetch
444+
.mockResolvedValueOnce(jsonResponse(200, { access_token: 'sf-jwt-token' }))
445+
.mockResolvedValueOnce(jsonResponse(403, {}))
446+
447+
await mintSalesforceServiceAccountToken(JWT_FIELDS)
448+
449+
const { claims } = readPostedAssertion()
450+
expect(claims.iat).toBeLessThanOrEqual(Math.floor(Date.now() / 1000))
451+
expect(claims.exp).toBeGreaterThan(claims.iat)
452+
})
453+
454+
it('maps an unassigned profile to a permission-set hint', async () => {
455+
mockFetch.mockResolvedValueOnce(
456+
jsonResponse(400, {
457+
error: 'invalid_app_access',
458+
error_description: 'user is not admin approved to access this app',
459+
})
460+
)
461+
462+
await expect(mintSalesforceServiceAccountToken(JWT_FIELDS)).rejects.toMatchObject({
463+
code: 'invalid_credentials',
464+
logDetail: { hint: expect.stringContaining('profile or permission set is not assigned') },
465+
})
466+
})
467+
425468
it('sets a short expiry inside the 5-minute window Salesforce allows', async () => {
426469
mockFetch
427470
.mockResolvedValueOnce(jsonResponse(200, { access_token: 'sf-jwt-token' }))

apps/sim/lib/credentials/client-credential-accounts/minters/salesforce.ts

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,10 @@ const IDENTITY_STEP = 'salesforce_identity'
3636
const TOKEN_MINT_STEP = 'salesforce_token_mint'
3737

3838
/**
39-
* Lifetime of the signed assertion, not of the access token it buys.
40-
* Salesforce rejects an `exp` more than 5 minutes ahead of *its* clock, so a
41-
* short window bounds replay while leaving room for clock skew between Sim and
42-
* Salesforce.
39+
* Lifetime of the signed assertion, not of the access token it buys. Salesforce
40+
* documents a 5-minute ceiling; a short window bounds replay while leaving room
41+
* for clock skew. The binding constraint is the lower bound — a Sim clock more
42+
* than this far behind Salesforce's fails every mint.
4343
*/
4444
const JWT_ASSERTION_LIFETIME_SECONDS = 180
4545

@@ -254,27 +254,43 @@ function loadSalesforcePrivateKey(privateKeyPem: string): KeyObject {
254254
* `test.salesforce.com`. Salesforce ended legacy hostname redirections in
255255
* Spring '26, and External Client Apps now reject the generic sandbox host
256256
* with `app_not_found` because it cannot identify which org the app is
257-
* installed in. The My Domain URL is valid for both Connected Apps and
258-
* External Client Apps, in production and in sandboxes, so it is the only
259-
* audience that works across all four combinations — and it means the stored
260-
* host alone determines the environment, with nothing left to infer.
261-
* Salesforce's own CLI recommends the My Domain login URL for the same reason.
257+
* installed in. My Domain is valid for Connected Apps and External Client Apps
258+
* alike, in production and in sandboxes, and is what Salesforce's own CLI
259+
* recommends — so the stored host alone determines the environment, with
260+
* nothing left to infer.
261+
*
262+
* Government Cloud is the documented exception: `sfdx-core` substitutes
263+
* `https://gs1.salesforce.com` for `gs1` orgs, whose hosts are otherwise
264+
* ordinary `*.my.salesforce.com`.
262265
*
263266
* @see https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_jwt_flow.htm&type=5
267+
* @see https://github.com/forcedotcom/sfdx-core/blob/main/src/util/sfdcUrl.ts
264268
*/
265269
function buildSalesforceJwtAssertion(
266270
consumerKey: string,
267271
username: string,
268272
host: string,
269273
privateKey: KeyObject
270274
): Promise<string> {
271-
return new SignJWT()
272-
.setProtectedHeader({ alg: 'RS256', typ: 'JWT' })
273-
.setIssuer(consumerKey)
274-
.setSubject(username)
275-
.setAudience(`https://${host}`)
276-
.setExpirationTime(Math.floor(Date.now() / 1000) + JWT_ASSERTION_LIFETIME_SECONDS)
277-
.sign(privateKey)
275+
return (
276+
new SignJWT()
277+
.setProtectedHeader({ alg: 'RS256', typ: 'JWT' })
278+
.setIssuer(consumerKey)
279+
.setSubject(username)
280+
.setAudience(salesforceJwtAudience(host))
281+
// Optional per RFC 7523, but every mainstream Salesforce implementation
282+
// (including `sfdx-core`) sends it; costs nothing to match them.
283+
.setIssuedAt()
284+
.setExpirationTime(Math.floor(Date.now() / 1000) + JWT_ASSERTION_LIFETIME_SECONDS)
285+
.sign(privateKey)
286+
)
287+
}
288+
289+
/** Government Cloud orgs authenticate at a dedicated audience; everyone else uses My Domain. */
290+
function salesforceJwtAudience(host: string): string {
291+
return host.startsWith('gs1.') || host.startsWith('gs1-')
292+
? 'https://gs1.salesforce.com'
293+
: `https://${host}`
278294
}
279295

280296
/**
@@ -290,6 +306,9 @@ function salesforceJwtErrorHint(body: string): string | undefined {
290306
if (parsed.error === 'app_not_found') {
291307
return 'the app is not installed in this org — check the My Domain host and that the consumer key belongs to that org'
292308
}
309+
if (parsed.error === 'invalid_app_access' || description.includes('admin approved')) {
310+
return "the run-as user's profile or permission set is not assigned to the app — assign it under the app's OAuth policies"
311+
}
293312
if (description.includes('user hasn’t approved') || description.includes("hasn't approved")) {
294313
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'
295314
}

0 commit comments

Comments
 (0)