Skip to content

Commit 44a3e44

Browse files
authored
fix(dataverse): harden OAuth connection preflight (#7238)
* fix(dataverse): harden OAuth connection preflight * fix(dataverse): preserve legacy reconnect behavior
1 parent 6ca5b52 commit 44a3e44

8 files changed

Lines changed: 164 additions & 40 deletions

File tree

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
1414
getBaseUrl: vi.fn(),
1515
requireClient: vi.fn(),
1616
createConnection: vi.fn(),
17+
getPerRequestScopes: vi.fn(),
1718
launchConnection: vi.fn(),
1819
}))
1920

@@ -46,6 +47,9 @@ vi.mock('@/lib/credentials/application/launch-credential-connection', () => ({
4647
execute: mocks.launchConnection,
4748
},
4849
}))
50+
vi.mock('@/lib/oauth/utils', () => ({
51+
getPerRequestOAuthLinkScopes: mocks.getPerRequestScopes,
52+
}))
4953

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

@@ -89,6 +93,7 @@ describe('OAuth2 authorize route', () => {
8993
},
9094
})
9195
mocks.linkAccount.mockResolvedValue(linkResponse())
96+
mocks.getPerRequestScopes.mockReturnValue(undefined)
9297
})
9398

9499
it('creates a canonical application draft for a legacy connect URL', async () => {
@@ -123,6 +128,29 @@ describe('OAuth2 authorize route', () => {
123128
expect(mocks.createConnection).not.toHaveBeenCalled()
124129
})
125130

131+
it('passes per-request scopes to providers that cannot inherit static connector scopes', async () => {
132+
const scopes = ['openid', 'https://dynamics.microsoft.com/user_impersonation']
133+
mocks.getPerRequestScopes.mockReturnValue(scopes)
134+
mocks.createConnection.mockResolvedValue({
135+
providerId: 'microsoft-dataverse',
136+
workspaceId: WORKSPACE_ID,
137+
draftId: 'draft-1',
138+
expiresAt: new Date(),
139+
authorizationUrl: '',
140+
})
141+
142+
await GET(request({ providerId: 'microsoft-dataverse', workspaceId: WORKSPACE_ID }))
143+
144+
expect(mocks.linkAccount).toHaveBeenCalledWith(
145+
expect.objectContaining({
146+
body: expect.objectContaining({
147+
providerId: 'microsoft-dataverse',
148+
scopes,
149+
}),
150+
})
151+
)
152+
})
153+
126154
it('launches an exact draft without creating another one', async () => {
127155
const response = await GET(request({ draftId: 'draft-1' }))
128156

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { CredentialConnectionProviderMismatchError } from '@/lib/credentials/app
1212
import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection'
1313
import { launchCredentialConnection } from '@/lib/credentials/application/launch-credential-connection'
1414
import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/draft-constants'
15+
import { getPerRequestOAuthLinkScopes } from '@/lib/oauth/utils'
1516

1617
const logger = createLogger('OAuth2Authorize')
1718

@@ -124,11 +125,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
124125

125126
const stateCallbackUrl = new URL(callbackURL)
126127
stateCallbackUrl.searchParams.set(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM, connectionDraftId)
128+
const scopes = getPerRequestOAuthLinkScopes(providerId)
127129

128130
const linkResponse = await auth.api.oAuth2LinkAccount({
129131
body: {
130132
providerId,
131133
callbackURL: stateCallbackUrl.toString(),
134+
...(scopes && { scopes }),
132135
...(fromConnectionDraft
133136
? { errorCallbackURL: `${baseUrl}/oauth/credential-connected?result=failed` }
134137
: {}),

apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
type WorkspaceCredential,
4646
} from '@/hooks/queries/credentials'
4747
import {
48+
assertMicrosoftDataverseReconnectAvailable,
4849
useConnectMicrosoftDataverseOAuthService,
4950
useMicrosoftDataverseCredentialBinding,
5051
} from '@/hooks/queries/oauth/microsoft-dataverse-connections'
@@ -128,19 +129,11 @@ export function ConnectedCredentialDetail({
128129
const handleReconnectOAuth = async () => {
129130
if (!credential || credential.type !== 'oauth' || !credential.providerId || !workspaceId) return
130131
try {
131-
if (
132-
isDataverseCredential &&
133-
dataverseCredentialQuery.isError &&
134-
!dataverseCredentialQuery.data?.[0]
135-
) {
136-
throw new Error(
137-
'Could not verify this Dataverse credential’s environment binding. Please try again.'
138-
)
139-
}
140-
if (dataverseBinding.state === 'invalid') {
141-
throw new Error(
142-
'This Dataverse credential has an invalid environment binding and cannot be reconnected in place.'
143-
)
132+
if (isDataverseCredential) {
133+
assertMicrosoftDataverseReconnectAvailable({
134+
bindingState: dataverseBinding.state,
135+
credentialQueryFailed: dataverseCredentialQuery.isError,
136+
})
144137
}
145138

146139
const draft = await createDraft.mutateAsync({

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx

Lines changed: 29 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import { useCallback, useEffect, useMemo, useState } from 'react'
4-
import { Button, Combobox, type ComboboxOptionGroup } from '@sim/emcn'
4+
import { Chip, Combobox, type ComboboxOptionGroup } from '@sim/emcn'
55
import { Key, SquareArrowUpRight } from '@sim/emcn/icons'
66
import { useParams } from 'next/navigation'
77
import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/credentials/client-state'
@@ -209,9 +209,10 @@ export function CredentialSelector({
209209
? getMissingRequiredScopes(selectedCredential!, requiredScopes || [])
210210
: []
211211
const needsUpdate =
212-
hasOAuthSelection &&
213212
!isServiceAccount &&
214-
(missingRequiredScopes.length > 0 || dataversePolicy.requiresSeparateCredential) &&
213+
(dataversePolicy.hasInvalidEnvironment ||
214+
(hasOAuthSelection &&
215+
(missingRequiredScopes.length > 0 || dataversePolicy.requiresSeparateCredential))) &&
215216
!effectiveDisabled &&
216217
!isPreview &&
217218
!credentialsLoading
@@ -474,29 +475,31 @@ export function CredentialSelector({
474475
<span className='mr-1.5 inline-block size-[6px] rounded-xs bg-amber-500' />
475476
{dataversePolicy.message}
476477
</div>
477-
<Button
478-
variant='active'
479-
onClick={() => {
480-
if (dataversePolicy.requiresSeparateCredential) {
481-
setShowConnectModal(true)
482-
return
483-
}
484-
writeOAuthReturnContext({
485-
origin: 'workflow',
486-
workflowId: activeWorkflowId || '',
487-
displayName: selectedCredential?.name ?? getProviderName(provider),
488-
providerId: effectiveProviderId,
489-
preCount: credentials.filter((c) => c.type !== 'service_account').length,
490-
workspaceId,
491-
reconnect: true,
492-
requestedAt: Date.now(),
493-
})
494-
setShowOAuthModal(true)
495-
}}
496-
className='w-full px-2 py-1 text-caption'
497-
>
498-
{dataversePolicy.actionLabel}
499-
</Button>
478+
{!dataversePolicy.hasInvalidEnvironment && (
479+
<Chip
480+
variant='primary'
481+
fullWidth
482+
onClick={() => {
483+
if (dataversePolicy.requiresSeparateCredential) {
484+
setShowConnectModal(true)
485+
return
486+
}
487+
writeOAuthReturnContext({
488+
origin: 'workflow',
489+
workflowId: activeWorkflowId || '',
490+
displayName: selectedCredential?.name ?? getProviderName(provider),
491+
providerId: effectiveProviderId,
492+
preCount: credentials.filter((c) => c.type !== 'service_account').length,
493+
workspaceId,
494+
reconnect: true,
495+
requestedAt: Date.now(),
496+
})
497+
setShowOAuthModal(true)
498+
}}
499+
>
500+
{dataversePolicy.actionLabel}
501+
</Chip>
502+
)}
500503
</div>
501504
)}
502505

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/microsoft-dataverse-policy.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,21 @@ describe('resolveMicrosoftDataverseCredentialPolicy', () => {
8888
expect(policy).toMatchObject({
8989
applies: true,
9090
bindingState: null,
91+
hasInvalidEnvironment: true,
9192
requiredScopes: [],
9293
requiresSeparateCredential: false,
9394
})
9495
expect(policy.environmentUrl).toBeUndefined()
9596
})
97+
98+
it('surfaces an invalid requested environment when a credential is already selected', () => {
99+
expect(resolve([], 'https://evil.example')).toMatchObject({
100+
applies: true,
101+
bindingState: 'invalid',
102+
hasInvalidEnvironment: true,
103+
message: 'Enter a valid Dynamics environment before selecting a credential',
104+
requiredScopes: [],
105+
requiresSeparateCredential: false,
106+
})
107+
})
96108
})

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/microsoft-dataverse-policy.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export interface MicrosoftDataverseCredentialPolicy {
1919
applies: boolean
2020
bindingState: MicrosoftDataverseCredentialEnvironmentState | null
2121
environmentUrl?: string
22+
hasInvalidEnvironment: boolean
2223
message: string
2324
requiredScopes: string[]
2425
requiresSeparateCredential: boolean
@@ -28,6 +29,7 @@ const DEFAULT_POLICY: MicrosoftDataverseCredentialPolicy = {
2829
actionLabel: 'Update access',
2930
applies: false,
3031
bindingState: null,
32+
hasInvalidEnvironment: false,
3133
message: 'Additional permissions required',
3234
requiredScopes: [],
3335
requiresSeparateCredential: false,
@@ -52,6 +54,7 @@ export function resolveMicrosoftDataverseCredentialPolicy({
5254
...DEFAULT_POLICY,
5355
applies: true,
5456
bindingState: hasSelectedCredential ? 'invalid' : null,
57+
hasInvalidEnvironment: true,
5558
message: 'Enter a valid Dynamics environment before selecting a credential',
5659
}
5760
}
@@ -71,6 +74,7 @@ export function resolveMicrosoftDataverseCredentialPolicy({
7174
applies: true,
7275
bindingState,
7376
environmentUrl: normalizedEnvironmentUrl,
77+
hasInvalidEnvironment: false,
7478
message: requiresSeparateCredential
7579
? 'This credential is not connected to this Dynamics environment'
7680
: 'Additional permissions required',

apps/sim/hooks/queries/oauth/microsoft-dataverse-connections.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ vi.mock('@/lib/desktop', () => ({
2525

2626
import { getMicrosoftDataverseRequiredScope } from '@/lib/oauth/microsoft-dataverse'
2727
import {
28+
assertMicrosoftDataverseReconnectAvailable,
2829
assertMicrosoftDataverseWebOAuthAvailable,
2930
buildMicrosoftDataverseOAuthLinkRequest,
3031
useConnectMicrosoftDataverseOAuthService,
@@ -120,6 +121,26 @@ describe('Microsoft Dataverse OAuth connections', () => {
120121
hook.unmount()
121122
})
122123

124+
it('rejects Better Auth link errors instead of reporting a successful redirect', async () => {
125+
mockLink.mockResolvedValue({
126+
data: null,
127+
error: {
128+
message: 'OAuth state could not be created',
129+
status: 500,
130+
statusText: 'Failed',
131+
},
132+
})
133+
const hook = renderHookWithClient(useConnectMicrosoftDataverseOAuthService)
134+
135+
await expect(
136+
hook.result().mutateAsync({
137+
callbackURL: 'https://sim.test/workflow',
138+
environmentUrl: 'https://contoso.crm.dynamics.com',
139+
})
140+
).rejects.toThrow('OAuth state could not be created')
141+
hook.unmount()
142+
})
143+
123144
it('rejects invalid environments and desktop initiation before linking', async () => {
124145
const webHook = renderHookWithClient(useConnectMicrosoftDataverseOAuthService)
125146
await expect(
@@ -143,6 +164,35 @@ describe('Microsoft Dataverse OAuth connections', () => {
143164
desktopHook.unmount()
144165
})
145166

167+
it('fails every reconnect precondition before the caller creates a draft', () => {
168+
expect(() =>
169+
assertMicrosoftDataverseReconnectAvailable({
170+
bindingState: 'bound',
171+
credentialQueryFailed: true,
172+
})
173+
).toThrow('Could not verify')
174+
expect(() =>
175+
assertMicrosoftDataverseReconnectAvailable({
176+
bindingState: 'invalid',
177+
credentialQueryFailed: false,
178+
})
179+
).toThrow('invalid environment binding')
180+
181+
mockBeginOAuthConnect.mockName('desktop')
182+
expect(() =>
183+
assertMicrosoftDataverseReconnectAvailable({
184+
bindingState: 'bound',
185+
credentialQueryFailed: false,
186+
})
187+
).toThrow('Sim web app')
188+
expect(() =>
189+
assertMicrosoftDataverseReconnectAvailable({
190+
bindingState: 'legacy',
191+
credentialQueryFailed: false,
192+
})
193+
).not.toThrow()
194+
})
195+
146196
it.each([
147197
['not-dataverse', 'salesforce', [], false],
148198
['legacy', 'microsoft-dataverse', ['https://dynamics.microsoft.com/user_impersonation'], false],

apps/sim/hooks/queries/oauth/microsoft-dataverse-connections.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
23
import { useMutation, useQueryClient } from '@tanstack/react-query'
34
import { client } from '@/lib/auth/auth-client'
45
import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/draft-constants'
@@ -53,6 +54,28 @@ export function assertMicrosoftDataverseWebOAuthAvailable(): void {
5354
}
5455
}
5556

57+
interface AssertMicrosoftDataverseReconnectAvailableParams {
58+
bindingState: MicrosoftDataverseCredentialBindingState
59+
credentialQueryFailed: boolean
60+
}
61+
62+
export function assertMicrosoftDataverseReconnectAvailable({
63+
bindingState,
64+
credentialQueryFailed,
65+
}: AssertMicrosoftDataverseReconnectAvailableParams): void {
66+
if (credentialQueryFailed) {
67+
throw new Error(
68+
'Could not verify this Dataverse credential’s environment binding. Please try again.'
69+
)
70+
}
71+
if (bindingState === 'invalid') {
72+
throw new Error(
73+
'This Dataverse credential has an invalid environment binding and cannot be reconnected in place.'
74+
)
75+
}
76+
if (bindingState === 'bound') assertMicrosoftDataverseWebOAuthAvailable()
77+
}
78+
5679
export function useConnectMicrosoftDataverseOAuthService() {
5780
const queryClient = useQueryClient()
5881

@@ -61,7 +84,15 @@ export function useConnectMicrosoftDataverseOAuthService() {
6184
assertMicrosoftDataverseWebOAuthAvailable()
6285
const request = buildMicrosoftDataverseOAuthLinkRequest(params)
6386

64-
await client.oauth2.link(request)
87+
const result = await client.oauth2.link(request)
88+
if (result.error) {
89+
throw new Error(
90+
getErrorMessage(
91+
result.error.message,
92+
result.error.statusText || 'Failed to start Microsoft Dataverse OAuth'
93+
)
94+
)
95+
}
6596
return { success: true }
6697
},
6798
onError: (error) => {

0 commit comments

Comments
 (0)