Skip to content

Commit ac16fcb

Browse files
committed
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".
1 parent 48ffd46 commit ac16fcb

5 files changed

Lines changed: 98 additions & 5 deletions

File tree

apps/sim/app/api/auth/oauth2/authorize/route.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,19 @@ vi.mock('@/lib/credentials/access', () => ({
3737

3838
vi.mock('@/lib/oauth/utils', () => ({
3939
getAllOAuthServices: vi.fn(() => [{ providerId: 'google-email', name: 'Gmail' }]),
40+
// Real implementation: a credential id matches its service's OAuth id, an
41+
// alternate authorization server, or the family's service-account id.
42+
credentialProviderMatchesService: (
43+
credentialProviderId: string,
44+
service: {
45+
providerId: string
46+
serviceAccountProviderId?: string
47+
additionalProviderIds?: readonly string[]
48+
}
49+
) =>
50+
service.providerId === credentialProviderId ||
51+
service.serviceAccountProviderId === credentialProviderId ||
52+
(service.additionalProviderIds?.includes(credentialProviderId) ?? false),
4053
}))
4154

4255
import { GET } from '@/app/api/auth/oauth2/authorize/route'

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
} from '@/lib/credentials/oauth-chat-attempt'
2323
import { getDesktopBridge } from '@/lib/desktop'
2424
import type { OAuthProvider } from '@/lib/oauth/types'
25-
import { parseProvider } from '@/lib/oauth/utils'
25+
import { parseProvider, providerIdsForService } from '@/lib/oauth/utils'
2626
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
2727

2828
const OAUTH_POPUP_WINDOW_NAME = 'sim-oauth-connect'
@@ -188,7 +188,15 @@ export function useOAuthChipConnection({
188188
} | null>(null)
189189

190190
const credentialTarget = useMemo(
191-
() => ({ providerId, baseProviderId, credentialId: reconnectCredentialId }),
191+
() => ({
192+
providerId,
193+
baseProviderId,
194+
credentialId: reconnectCredentialId,
195+
// A credential from an alternate authorization server (Salesforce
196+
// sandbox) still connects this chip's service; without these the chip
197+
// reads as disconnected and re-prompts a user who is already connected.
198+
additionalProviderIds: providerIdsForService(providerId),
199+
}),
192200
[baseProviderId, providerId, reconnectCredentialId]
193201
)
194202
const credentialScope = `${workspaceId}:${providerId}:${reconnectCredentialId ?? ''}`

apps/sim/lib/credentials/connect-draft.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { createLogger } from '@sim/logger'
44
import { generateId } from '@sim/utils/id'
55
import { and, eq, lt } from 'drizzle-orm'
66
import { defaultCredentialDisplayName } from '@/lib/credentials/display-name'
7-
import { getAllOAuthServices } from '@/lib/oauth/utils'
7+
import { credentialProviderMatchesService, getAllOAuthServices } from '@/lib/oauth/utils'
88

99
const logger = createLogger('OAuthConnectDraft')
1010
const DRAFT_TTL_MS = 15 * 60 * 1000
@@ -26,7 +26,12 @@ export async function createConnectDraft(params: {
2626

2727
let displayName = params.displayName
2828
if (!displayName) {
29-
const service = getAllOAuthServices().find((s) => s.providerId === providerId)
29+
// Matches through the canonical predicate so an alternate authorization
30+
// server's id resolves the service's real name — otherwise the default
31+
// label reads "My salesforce-sandbox".
32+
const service = getAllOAuthServices().find((s) =>
33+
credentialProviderMatchesService(providerId, s)
34+
)
3035
const serviceName = service?.name ?? providerId
3136

3237
let userName: string | null = null

apps/sim/lib/credentials/oauth-chat-attempt.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
createOAuthChatAttempt,
1111
getOAuthCredentialBaseline,
1212
hasOAuthCredentialChanged,
13+
hasOAuthCredentialForTarget,
1314
OAUTH_CHAT_ATTEMPT_EVENT,
1415
OAUTH_CHAT_ATTEMPT_PARAM,
1516
OAUTH_CHAT_COMPLETE_PATH,
@@ -254,3 +255,59 @@ describe('OAuth chat attempts', () => {
254255
).toBe(true)
255256
})
256257
})
258+
259+
describe('credential matching for a chat chip', () => {
260+
const credential = (providerId: string) => ({
261+
id: `cred-${providerId}`,
262+
providerId,
263+
updatedAt: '2026-08-11T00:00:00.000Z',
264+
})
265+
266+
it('matches a credential from an alternate authorization server', () => {
267+
// A sandbox-only user is connected; without this the chip reads as
268+
// disconnected and re-prompts them to connect Salesforce again.
269+
expect(
270+
hasOAuthCredentialForTarget(
271+
{
272+
providerId: 'salesforce',
273+
baseProviderId: 'salesforce',
274+
additionalProviderIds: ['salesforce-sandbox'],
275+
},
276+
[credential('salesforce-sandbox')]
277+
)
278+
).toBe(true)
279+
})
280+
281+
it('still matches the primary id and the base provider', () => {
282+
const target = { providerId: 'google-email', baseProviderId: 'google' }
283+
expect(hasOAuthCredentialForTarget(target, [credential('google-email')])).toBe(true)
284+
expect(hasOAuthCredentialForTarget(target, [credential('google')])).toBe(true)
285+
})
286+
287+
it('does not match an unrelated provider', () => {
288+
expect(
289+
hasOAuthCredentialForTarget(
290+
{
291+
providerId: 'salesforce',
292+
baseProviderId: 'salesforce',
293+
additionalProviderIds: ['salesforce-sandbox'],
294+
},
295+
[credential('hubspot')]
296+
)
297+
).toBe(false)
298+
})
299+
300+
it('honours an explicit credentialId over any provider match', () => {
301+
expect(
302+
hasOAuthCredentialForTarget(
303+
{
304+
providerId: 'salesforce',
305+
baseProviderId: 'salesforce',
306+
credentialId: 'cred-other',
307+
additionalProviderIds: ['salesforce-sandbox'],
308+
},
309+
[credential('salesforce-sandbox')]
310+
)
311+
).toBe(false)
312+
})
313+
})

apps/sim/lib/credentials/oauth-chat-attempt.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ export interface OAuthCredentialTarget {
2424
providerId: string
2525
baseProviderId: string
2626
credentialId?: string
27+
/**
28+
* Alternate authorization servers whose credentials authenticate this same
29+
* service (`salesforce-sandbox`). Supplied by the caller rather than resolved
30+
* here so this module stays free of the OAuth provider registry, which would
31+
* otherwise pull icon components into the chat bundle.
32+
*/
33+
additionalProviderIds?: readonly string[]
2734
}
2835

2936
export interface OAuthCredentialBaseline {
@@ -65,7 +72,10 @@ function credentialsForTarget(
6572
}
6673
return credentials.filter(
6774
(credential) =>
68-
credential.providerId === target.providerId || credential.providerId === target.baseProviderId
75+
credential.providerId === target.providerId ||
76+
credential.providerId === target.baseProviderId ||
77+
(credential.providerId !== null &&
78+
(target.additionalProviderIds?.includes(credential.providerId) ?? false))
6979
)
7080
}
7181

0 commit comments

Comments
 (0)