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 f59ae8b4dc6..ab527378a22 100644
--- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts
+++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts
@@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
getBaseUrl: vi.fn(),
requireClient: vi.fn(),
createConnection: vi.fn(),
+ getPerRequestScopes: vi.fn(),
launchConnection: vi.fn(),
}))
@@ -46,6 +47,9 @@ vi.mock('@/lib/credentials/application/launch-credential-connection', () => ({
execute: mocks.launchConnection,
},
}))
+vi.mock('@/lib/oauth/utils', () => ({
+ getPerRequestOAuthLinkScopes: mocks.getPerRequestScopes,
+}))
import { GET } from '@/app/api/auth/oauth2/authorize/route'
@@ -89,6 +93,7 @@ describe('OAuth2 authorize route', () => {
},
})
mocks.linkAccount.mockResolvedValue(linkResponse())
+ mocks.getPerRequestScopes.mockReturnValue(undefined)
})
it('creates a canonical application draft for a legacy connect URL', async () => {
@@ -123,6 +128,29 @@ describe('OAuth2 authorize route', () => {
expect(mocks.createConnection).not.toHaveBeenCalled()
})
+ it('passes per-request scopes to providers that cannot inherit static connector scopes', async () => {
+ const scopes = ['openid', 'https://dynamics.microsoft.com/user_impersonation']
+ mocks.getPerRequestScopes.mockReturnValue(scopes)
+ mocks.createConnection.mockResolvedValue({
+ providerId: 'microsoft-dataverse',
+ workspaceId: WORKSPACE_ID,
+ draftId: 'draft-1',
+ expiresAt: new Date(),
+ authorizationUrl: '',
+ })
+
+ await GET(request({ providerId: 'microsoft-dataverse', workspaceId: WORKSPACE_ID }))
+
+ expect(mocks.linkAccount).toHaveBeenCalledWith(
+ expect.objectContaining({
+ body: expect.objectContaining({
+ providerId: 'microsoft-dataverse',
+ scopes,
+ }),
+ })
+ )
+ })
+
it('launches an exact draft without creating another one', async () => {
const response = await GET(request({ draftId: 'draft-1' }))
diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts
index 3a1f4beb350..e15d0ad6074 100644
--- a/apps/sim/app/api/auth/oauth2/authorize/route.ts
+++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts
@@ -12,6 +12,7 @@ import { CredentialConnectionProviderMismatchError } from '@/lib/credentials/app
import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection'
import { launchCredentialConnection } from '@/lib/credentials/application/launch-credential-connection'
import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/draft-constants'
+import { getPerRequestOAuthLinkScopes } from '@/lib/oauth/utils'
const logger = createLogger('OAuth2Authorize')
@@ -124,11 +125,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
const stateCallbackUrl = new URL(callbackURL)
stateCallbackUrl.searchParams.set(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM, connectionDraftId)
+ const scopes = getPerRequestOAuthLinkScopes(providerId)
const linkResponse = await auth.api.oAuth2LinkAccount({
body: {
providerId,
callbackURL: stateCallbackUrl.toString(),
+ ...(scopes && { scopes }),
...(fromConnectionDraft
? { errorCallbackURL: `${baseUrl}/oauth/credential-connected?result=failed` }
: {}),
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx
index a4df76aeb2e..914fe2d366b 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx
@@ -45,6 +45,7 @@ import {
type WorkspaceCredential,
} from '@/hooks/queries/credentials'
import {
+ assertMicrosoftDataverseReconnectAvailable,
useConnectMicrosoftDataverseOAuthService,
useMicrosoftDataverseCredentialBinding,
} from '@/hooks/queries/oauth/microsoft-dataverse-connections'
@@ -128,19 +129,11 @@ export function ConnectedCredentialDetail({
const handleReconnectOAuth = async () => {
if (!credential || credential.type !== 'oauth' || !credential.providerId || !workspaceId) return
try {
- if (
- isDataverseCredential &&
- dataverseCredentialQuery.isError &&
- !dataverseCredentialQuery.data?.[0]
- ) {
- throw new Error(
- 'Could not verify this Dataverse credential’s environment binding. Please try again.'
- )
- }
- if (dataverseBinding.state === 'invalid') {
- throw new Error(
- 'This Dataverse credential has an invalid environment binding and cannot be reconnected in place.'
- )
+ if (isDataverseCredential) {
+ assertMicrosoftDataverseReconnectAvailable({
+ bindingState: dataverseBinding.state,
+ credentialQueryFailed: dataverseCredentialQuery.isError,
+ })
}
const draft = await createDraft.mutateAsync({
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 2d8782a0257..d261b012e45 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
@@ -1,7 +1,7 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
-import { Button, Combobox, type ComboboxOptionGroup } from '@sim/emcn'
+import { Chip, Combobox, type ComboboxOptionGroup } from '@sim/emcn'
import { Key, SquareArrowUpRight } from '@sim/emcn/icons'
import { useParams } from 'next/navigation'
import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/credentials/client-state'
@@ -209,9 +209,10 @@ export function CredentialSelector({
? getMissingRequiredScopes(selectedCredential!, requiredScopes || [])
: []
const needsUpdate =
- hasOAuthSelection &&
!isServiceAccount &&
- (missingRequiredScopes.length > 0 || dataversePolicy.requiresSeparateCredential) &&
+ (dataversePolicy.hasInvalidEnvironment ||
+ (hasOAuthSelection &&
+ (missingRequiredScopes.length > 0 || dataversePolicy.requiresSeparateCredential))) &&
!effectiveDisabled &&
!isPreview &&
!credentialsLoading
@@ -474,29 +475,31 @@ export function CredentialSelector({
{dataversePolicy.message}
-
+ {!dataversePolicy.hasInvalidEnvironment && (
+ {
+ if (dataversePolicy.requiresSeparateCredential) {
+ setShowConnectModal(true)
+ return
+ }
+ writeOAuthReturnContext({
+ origin: 'workflow',
+ workflowId: activeWorkflowId || '',
+ displayName: selectedCredential?.name ?? getProviderName(provider),
+ providerId: effectiveProviderId,
+ preCount: credentials.filter((c) => c.type !== 'service_account').length,
+ workspaceId,
+ reconnect: true,
+ requestedAt: Date.now(),
+ })
+ setShowOAuthModal(true)
+ }}
+ >
+ {dataversePolicy.actionLabel}
+
+ )}
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/microsoft-dataverse-policy.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/microsoft-dataverse-policy.test.ts
index e25f4ac3c8b..9f0d6aa8e92 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/microsoft-dataverse-policy.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/microsoft-dataverse-policy.test.ts
@@ -88,9 +88,21 @@ describe('resolveMicrosoftDataverseCredentialPolicy', () => {
expect(policy).toMatchObject({
applies: true,
bindingState: null,
+ hasInvalidEnvironment: true,
requiredScopes: [],
requiresSeparateCredential: false,
})
expect(policy.environmentUrl).toBeUndefined()
})
+
+ it('surfaces an invalid requested environment when a credential is already selected', () => {
+ expect(resolve([], 'https://evil.example')).toMatchObject({
+ applies: true,
+ bindingState: 'invalid',
+ hasInvalidEnvironment: true,
+ message: 'Enter a valid Dynamics environment before selecting a credential',
+ requiredScopes: [],
+ requiresSeparateCredential: false,
+ })
+ })
})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/microsoft-dataverse-policy.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/microsoft-dataverse-policy.ts
index 76289ec81f9..7ef2718bfb6 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/microsoft-dataverse-policy.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/microsoft-dataverse-policy.ts
@@ -19,6 +19,7 @@ export interface MicrosoftDataverseCredentialPolicy {
applies: boolean
bindingState: MicrosoftDataverseCredentialEnvironmentState | null
environmentUrl?: string
+ hasInvalidEnvironment: boolean
message: string
requiredScopes: string[]
requiresSeparateCredential: boolean
@@ -28,6 +29,7 @@ const DEFAULT_POLICY: MicrosoftDataverseCredentialPolicy = {
actionLabel: 'Update access',
applies: false,
bindingState: null,
+ hasInvalidEnvironment: false,
message: 'Additional permissions required',
requiredScopes: [],
requiresSeparateCredential: false,
@@ -52,6 +54,7 @@ export function resolveMicrosoftDataverseCredentialPolicy({
...DEFAULT_POLICY,
applies: true,
bindingState: hasSelectedCredential ? 'invalid' : null,
+ hasInvalidEnvironment: true,
message: 'Enter a valid Dynamics environment before selecting a credential',
}
}
@@ -71,6 +74,7 @@ export function resolveMicrosoftDataverseCredentialPolicy({
applies: true,
bindingState,
environmentUrl: normalizedEnvironmentUrl,
+ hasInvalidEnvironment: false,
message: requiresSeparateCredential
? 'This credential is not connected to this Dynamics environment'
: 'Additional permissions required',
diff --git a/apps/sim/hooks/queries/oauth/microsoft-dataverse-connections.test.ts b/apps/sim/hooks/queries/oauth/microsoft-dataverse-connections.test.ts
index bce7ddb94d3..d054fa5ea6a 100644
--- a/apps/sim/hooks/queries/oauth/microsoft-dataverse-connections.test.ts
+++ b/apps/sim/hooks/queries/oauth/microsoft-dataverse-connections.test.ts
@@ -25,6 +25,7 @@ vi.mock('@/lib/desktop', () => ({
import { getMicrosoftDataverseRequiredScope } from '@/lib/oauth/microsoft-dataverse'
import {
+ assertMicrosoftDataverseReconnectAvailable,
assertMicrosoftDataverseWebOAuthAvailable,
buildMicrosoftDataverseOAuthLinkRequest,
useConnectMicrosoftDataverseOAuthService,
@@ -120,6 +121,26 @@ describe('Microsoft Dataverse OAuth connections', () => {
hook.unmount()
})
+ it('rejects Better Auth link errors instead of reporting a successful redirect', async () => {
+ mockLink.mockResolvedValue({
+ data: null,
+ error: {
+ message: 'OAuth state could not be created',
+ status: 500,
+ statusText: 'Failed',
+ },
+ })
+ const hook = renderHookWithClient(useConnectMicrosoftDataverseOAuthService)
+
+ await expect(
+ hook.result().mutateAsync({
+ callbackURL: 'https://sim.test/workflow',
+ environmentUrl: 'https://contoso.crm.dynamics.com',
+ })
+ ).rejects.toThrow('OAuth state could not be created')
+ hook.unmount()
+ })
+
it('rejects invalid environments and desktop initiation before linking', async () => {
const webHook = renderHookWithClient(useConnectMicrosoftDataverseOAuthService)
await expect(
@@ -143,6 +164,35 @@ describe('Microsoft Dataverse OAuth connections', () => {
desktopHook.unmount()
})
+ it('fails every reconnect precondition before the caller creates a draft', () => {
+ expect(() =>
+ assertMicrosoftDataverseReconnectAvailable({
+ bindingState: 'bound',
+ credentialQueryFailed: true,
+ })
+ ).toThrow('Could not verify')
+ expect(() =>
+ assertMicrosoftDataverseReconnectAvailable({
+ bindingState: 'invalid',
+ credentialQueryFailed: false,
+ })
+ ).toThrow('invalid environment binding')
+
+ mockBeginOAuthConnect.mockName('desktop')
+ expect(() =>
+ assertMicrosoftDataverseReconnectAvailable({
+ bindingState: 'bound',
+ credentialQueryFailed: false,
+ })
+ ).toThrow('Sim web app')
+ expect(() =>
+ assertMicrosoftDataverseReconnectAvailable({
+ bindingState: 'legacy',
+ credentialQueryFailed: false,
+ })
+ ).not.toThrow()
+ })
+
it.each([
['not-dataverse', 'salesforce', [], false],
['legacy', 'microsoft-dataverse', ['https://dynamics.microsoft.com/user_impersonation'], false],
diff --git a/apps/sim/hooks/queries/oauth/microsoft-dataverse-connections.ts b/apps/sim/hooks/queries/oauth/microsoft-dataverse-connections.ts
index b41556eb933..254cf997d48 100644
--- a/apps/sim/hooks/queries/oauth/microsoft-dataverse-connections.ts
+++ b/apps/sim/hooks/queries/oauth/microsoft-dataverse-connections.ts
@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { client } from '@/lib/auth/auth-client'
import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/draft-constants'
@@ -53,6 +54,28 @@ export function assertMicrosoftDataverseWebOAuthAvailable(): void {
}
}
+interface AssertMicrosoftDataverseReconnectAvailableParams {
+ bindingState: MicrosoftDataverseCredentialBindingState
+ credentialQueryFailed: boolean
+}
+
+export function assertMicrosoftDataverseReconnectAvailable({
+ bindingState,
+ credentialQueryFailed,
+}: AssertMicrosoftDataverseReconnectAvailableParams): void {
+ if (credentialQueryFailed) {
+ throw new Error(
+ 'Could not verify this Dataverse credential’s environment binding. Please try again.'
+ )
+ }
+ if (bindingState === 'invalid') {
+ throw new Error(
+ 'This Dataverse credential has an invalid environment binding and cannot be reconnected in place.'
+ )
+ }
+ if (bindingState === 'bound') assertMicrosoftDataverseWebOAuthAvailable()
+}
+
export function useConnectMicrosoftDataverseOAuthService() {
const queryClient = useQueryClient()
@@ -61,7 +84,15 @@ export function useConnectMicrosoftDataverseOAuthService() {
assertMicrosoftDataverseWebOAuthAvailable()
const request = buildMicrosoftDataverseOAuthLinkRequest(params)
- await client.oauth2.link(request)
+ const result = await client.oauth2.link(request)
+ if (result.error) {
+ throw new Error(
+ getErrorMessage(
+ result.error.message,
+ result.error.statusText || 'Failed to start Microsoft Dataverse OAuth'
+ )
+ )
+ }
return { success: true }
},
onError: (error) => {