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) => {