Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions apps/sim/app/api/auth/oauth2/authorize/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
getBaseUrl: vi.fn(),
requireClient: vi.fn(),
createConnection: vi.fn(),
getPerRequestScopes: vi.fn(),
launchConnection: vi.fn(),
}))

Expand Down Expand Up @@ -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'

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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' }))

Expand Down
3 changes: 3 additions & 0 deletions apps/sim/app/api/auth/oauth2/authorize/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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` }
: {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
type WorkspaceCredential,
} from '@/hooks/queries/credentials'
import {
assertMicrosoftDataverseReconnectAvailable,
useConnectMicrosoftDataverseOAuthService,
useMicrosoftDataverseCredentialBinding,
} from '@/hooks/queries/oauth/microsoft-dataverse-connections'
Expand Down Expand Up @@ -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({
Comment thread
waleedlatif1 marked this conversation as resolved.
bindingState: dataverseBinding.state,
credentialQueryFailed: dataverseCredentialQuery.isError,
})
}

const draft = await createDraft.mutateAsync({
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -474,29 +475,31 @@ export function CredentialSelector({
<span className='mr-1.5 inline-block size-[6px] rounded-xs bg-amber-500' />
{dataversePolicy.message}
</div>
<Button
variant='active'
onClick={() => {
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)
}}
className='w-full px-2 py-1 text-caption'
>
{dataversePolicy.actionLabel}
</Button>
{!dataversePolicy.hasInvalidEnvironment && (
<Chip
variant='primary'
fullWidth
onClick={() => {
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}
</Chip>
)}
</div>
)}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface MicrosoftDataverseCredentialPolicy {
applies: boolean
bindingState: MicrosoftDataverseCredentialEnvironmentState | null
environmentUrl?: string
hasInvalidEnvironment: boolean
message: string
requiredScopes: string[]
requiresSeparateCredential: boolean
Expand All @@ -28,6 +29,7 @@ const DEFAULT_POLICY: MicrosoftDataverseCredentialPolicy = {
actionLabel: 'Update access',
applies: false,
bindingState: null,
hasInvalidEnvironment: false,
message: 'Additional permissions required',
requiredScopes: [],
requiresSeparateCredential: false,
Expand All @@ -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',
}
}
Expand All @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ vi.mock('@/lib/desktop', () => ({

import { getMicrosoftDataverseRequiredScope } from '@/lib/oauth/microsoft-dataverse'
import {
assertMicrosoftDataverseReconnectAvailable,
assertMicrosoftDataverseWebOAuthAvailable,
buildMicrosoftDataverseOAuthLinkRequest,
useConnectMicrosoftDataverseOAuthService,
Expand Down Expand Up @@ -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(
Expand All @@ -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],
Expand Down
33 changes: 32 additions & 1 deletion apps/sim/hooks/queries/oauth/microsoft-dataverse-connections.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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()

Expand All @@ -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) => {
Expand Down
Loading