Skip to content

Commit e7a9884

Browse files
committed
fix(integrations): harden Dynamics OAuth binding
1 parent 8164dc3 commit e7a9884

24 files changed

Lines changed: 220 additions & 131 deletions

apps/sim/app/desktop/connect/connect-launcher.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
44
import { Chip } from '@sim/emcn'
55
import { getErrorMessage } from '@sim/utils/errors'
66
import { client } from '@/lib/auth/auth-client'
7+
import { getPerRequestOAuthLinkScopes } from '@/lib/oauth/utils'
78
import { DesktopHandoffShell } from '@/app/desktop/components/desktop-handoff-shell'
89

910
interface ConnectLauncherProps {
@@ -31,9 +32,11 @@ export function ConnectLauncher({ providerId, completeUrl }: ConnectLauncherProp
3132
const start = useCallback(async () => {
3233
setError(null)
3334
try {
35+
const scopes = getPerRequestOAuthLinkScopes(providerId)
3436
await client.oauth2.link({
3537
providerId,
3638
callbackURL: completeUrl,
39+
...(scopes && { scopes }),
3740
// Failed flows bounce to the same complete page (which forwards the
3841
// failure to the loopback) instead of waiting out the handoff TTL.
3942
// Do NOT bake in a query param here: better-auth appends its own

apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/microsoft-dataverse-environment.test.ts

Lines changed: 0 additions & 101 deletions
This file was deleted.

apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/microsoft-dataverse-environment.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,12 @@ import { useState } from 'react'
44
import { ChipModalField } from '@sim/emcn'
55
import { getErrorMessage } from '@sim/utils/errors'
66
import {
7+
getMicrosoftDataverseIdentityScopes,
78
getMicrosoftDataverseOAuthScopes,
89
MICROSOFT_DATAVERSE_PROVIDER_ID,
910
normalizeMicrosoftDataverseEnvironmentUrl,
1011
} from '@/lib/oauth/microsoft-dataverse'
1112

12-
const DYNAMICS_IDENTITY_SCOPES = ['openid', 'profile', 'email', 'offline_access'] as const
13-
1413
interface UseMicrosoftDataverseEnvironmentFormProps {
1514
fallbackScopes: readonly string[]
1615
lockedEnvironmentUrl?: string
@@ -71,7 +70,7 @@ export function useMicrosoftDataverseEnvironmentForm({
7170

7271
const effectiveScopes = (() => {
7372
if (!enabled) return fallbackScopes
74-
if (!value.trim()) return DYNAMICS_IDENTITY_SCOPES
73+
if (!value.trim()) return getMicrosoftDataverseIdentityScopes(fallbackScopes)
7574
try {
7675
return getMicrosoftDataverseOAuthScopes(value)
7776
} catch {

apps/sim/hooks/queries/oauth/oauth-connections.test.tsx

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ const { beginOAuthConnect, oauthLink } = vi.hoisted(() => ({
1414
vi.mock('@/lib/api/client/request', () => ({ requestJson: vi.fn() }))
1515
vi.mock('@/lib/auth/auth-client', () => ({ client: { oauth2: { link: oauthLink } } }))
1616
vi.mock('@/lib/desktop', () => ({
17-
getDesktopBridge: () => ({ beginOAuthConnect }),
17+
getDesktopBridge: () =>
18+
beginOAuthConnect.getMockName() === 'desktop' ? { beginOAuthConnect } : null,
1819
}))
1920
vi.mock('@/lib/oauth', () => ({ OAUTH_PROVIDERS: {} }))
2021

@@ -25,6 +26,7 @@ describe('useConnectOAuthService', () => {
2526

2627
beforeEach(() => {
2728
vi.clearAllMocks()
29+
beginOAuthConnect.mockName('desktop')
2830
beginOAuthConnect.mockResolvedValue(true)
2931
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
3032
})
@@ -68,4 +70,50 @@ describe('useConnectOAuthService', () => {
6870
expect(oauthLink).not.toHaveBeenCalled()
6971
}
7072
)
73+
74+
it('supplies the canonical legacy scopes explicitly for web Dataverse links', async () => {
75+
beginOAuthConnect.mockName('web')
76+
oauthLink.mockResolvedValue({ data: {}, error: null })
77+
const queryClient = new QueryClient({
78+
defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
79+
})
80+
const container = document.createElement('div')
81+
const root = createRoot(container)
82+
let connect: ReturnType<typeof useConnectOAuthService> | undefined
83+
function Probe() {
84+
connect = useConnectOAuthService()
85+
return null
86+
}
87+
function Wrapper({ children }: { children: ReactNode }) {
88+
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
89+
}
90+
act(() =>
91+
root.render(
92+
<Wrapper>
93+
<Probe />
94+
</Wrapper>
95+
)
96+
)
97+
unmount = () => act(() => root.unmount())
98+
99+
await act(async () => {
100+
await connect?.mutateAsync({
101+
providerId: 'microsoft-dataverse',
102+
callbackURL: 'https://sim.test/oauth/credential-connected',
103+
draftId: 'draft-1',
104+
})
105+
})
106+
107+
expect(oauthLink).toHaveBeenCalledWith({
108+
providerId: 'microsoft-dataverse',
109+
callbackURL: 'https://sim.test/oauth/credential-connected?credentialDraftId=draft-1',
110+
scopes: [
111+
'openid',
112+
'profile',
113+
'email',
114+
'https://dynamics.microsoft.com/user_impersonation',
115+
'offline_access',
116+
],
117+
})
118+
})
71119
})

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { client } from '@/lib/auth/auth-client'
1111
import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/draft-constants'
1212
import { getDesktopBridge } from '@/lib/desktop'
1313
import { OAUTH_PROVIDERS, type OAuthServiceConfig } from '@/lib/oauth'
14+
import { getPerRequestOAuthLinkScopes } from '@/lib/oauth/utils'
1415

1516
const logger = createLogger('OAuthConnectionsQuery')
1617

@@ -193,9 +194,11 @@ export function useConnectOAuthService() {
193194
stateCallbackUrl.searchParams.set(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM, draftId)
194195
}
195196

197+
const scopes = getPerRequestOAuthLinkScopes(providerId)
196198
await client.oauth2.link({
197199
providerId,
198200
callbackURL: stateCallbackUrl.toString(),
201+
...(scopes && { scopes }),
199202
})
200203

201204
return { success: true }

apps/sim/lib/auth/connectors/providers.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -798,7 +798,11 @@ export function buildConnectorProviders(): GenericOAuthConfig[] {
798798
authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
799799
tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
800800
userInfoUrl: 'https://graph.microsoft.com/v1.0/me',
801-
scopes: getCanonicalScopesForProvider('microsoft-dataverse'),
801+
/**
802+
* Better Auth appends connector scopes to link-request scopes. Dataverse audiences are
803+
* request-specific, so every allowed link supplies its exact grant and this base stays empty.
804+
*/
805+
scopes: [],
802806
responseType: 'code',
803807
accessType: 'offline',
804808
authentication: 'basic',

apps/sim/lib/oauth/microsoft-dataverse.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
classifyMicrosoftDataverseCredentialEnvironment,
1111
extractMicrosoftDataverseEnvironmentUrl,
1212
getBoundMicrosoftDataverseEnvironment,
13+
getMicrosoftDataverseIdentityScopes,
1314
getMicrosoftDataverseOAuthScopes,
1415
getMicrosoftDataverseRequiredScope,
1516
normalizeMicrosoftDataverseEnvironmentUrl,
@@ -69,6 +70,15 @@ describe('Microsoft Dataverse OAuth environment binding', () => {
6970
])
7071
})
7172

73+
it('derives identity permissions from the canonical service grant', () => {
74+
expect(getMicrosoftDataverseIdentityScopes(LEGACY_DATAVERSE_SCOPES)).toEqual([
75+
'openid',
76+
'profile',
77+
'email',
78+
'offline_access',
79+
])
80+
})
81+
7282
it('round-trips the flow-bound environment through absolute and relative callback URLs', () => {
7383
const absolute = bindMicrosoftDataverseEnvironmentToOAuthCallback(
7484
'https://sim.test/workspace?existing=1',
@@ -126,7 +136,7 @@ describe('Microsoft Dataverse OAuth environment binding', () => {
126136
undefined,
127137
LEGACY_DATAVERSE_SCOPES
128138
)
129-
).not.toThrow()
139+
).toThrow('exact legacy scopes')
130140
expect(() =>
131141
assertMicrosoftDataverseOAuthLinkRequest(
132142
'https://sim.test/workspace',
@@ -283,6 +293,19 @@ describe('Microsoft Dataverse OAuth environment binding', () => {
283293
).toThrow('multiple environment scopes')
284294
})
285295

296+
it('rejects a malformed internal environment marker instead of treating it as legacy', () => {
297+
const malformedMarker = '__sim_dataverse_instance__:https://evil.example'
298+
expect(() => extractMicrosoftDataverseEnvironmentUrl([malformedMarker])).toThrow(
299+
'invalid environment scope'
300+
)
301+
expect(
302+
classifyMicrosoftDataverseCredentialEnvironment(
303+
[malformedMarker],
304+
'https://dev.crm.dynamics.com'
305+
)
306+
).toBe('invalid')
307+
})
308+
286309
it('classifies matching, legacy, different, and ambiguous stored grants', () => {
287310
const requested = 'https://dev.crm.dynamics.com'
288311
expect(
@@ -331,6 +354,14 @@ describe('Microsoft Dataverse OAuth environment binding', () => {
331354
expect(prod.id).toContain(':prod.api.crm.dynamics.com-')
332355
})
333356

357+
it('fails closed when the generated account ID invariant is missing', () => {
358+
expect(() =>
359+
bindMicrosoftDataverseEnvironmentToUserInfo({ id: 'entra-user-id' }, [
360+
getMicrosoftDataverseRequiredScope('https://dev.crm.dynamics.com'),
361+
])
362+
).toThrow('user ID is missing its generated suffix')
363+
})
364+
334365
it('rejects callback tokens without an environment audience', () => {
335366
expect(() =>
336367
bindMicrosoftDataverseEnvironmentToUserInfo(userInfoFor('entra-user-id'), [

apps/sim/lib/oauth/microsoft-dataverse.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@ export function getMicrosoftDataverseOAuthScopes(environmentUrl: unknown): strin
6767
]
6868
}
6969

70+
/** Keeps only non-resource permissions from the canonical Dataverse service grant for UI display. */
71+
export function getMicrosoftDataverseIdentityScopes(scopes: readonly string[]): string[] {
72+
return scopes.filter(
73+
(scope) => !/^https:\/\//i.test(scope) && !scope.startsWith(DATAVERSE_INSTANCE_MARKER_PREFIX)
74+
)
75+
}
76+
7077
/** Returns the trusted internal marker used to match a Dynamics credential in block UIs. */
7178
export function getMicrosoftDataverseRequiredScope(environmentUrl: unknown): string {
7279
const origin = normalizeMicrosoftDataverseEnvironmentUrl(environmentUrl)
@@ -130,7 +137,6 @@ export function assertMicrosoftDataverseOAuthLinkRequest(
130137
): void {
131138
const environment = getBoundMicrosoftDataverseEnvironment(callbackURL)
132139
if (!environment) {
133-
if (requestedScopes === undefined || requestedScopes === null) return
134140
if (
135141
!Array.isArray(requestedScopes) ||
136142
!requestedScopes.every((scope) => typeof scope === 'string')
@@ -258,7 +264,9 @@ export function extractMicrosoftDataverseEnvironmentUrl(
258264
const candidate = value.slice(DATAVERSE_INSTANCE_MARKER_PREFIX.length)
259265
try {
260266
origins.add(normalizeMicrosoftDataverseEnvironmentUrl(candidate))
261-
} catch {}
267+
} catch {
268+
throw new Error('Microsoft Dataverse credential contains an invalid environment scope')
269+
}
262270
}
263271

264272
if (origins.size > 1) {
@@ -308,6 +316,9 @@ export function bindMicrosoftDataverseEnvironmentToUserInfo<T extends { id: stri
308316
}
309317

310318
const environmentHost = new URL(environmentUrl).hostname
319+
if (!UUID_SUFFIX_RE.test(userInfo.id)) {
320+
throw new Error('Microsoft Dynamics 365 OAuth user ID is missing its generated suffix')
321+
}
311322
return {
312323
...userInfo,
313324
id: userInfo.id.replace(UUID_SUFFIX_RE, `:${environmentHost}$&`),

apps/sim/lib/oauth/oauth.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,12 @@ afterAll(resetEnvMock)
7070
import { GoogleIcon, GoogleVaultIcon } from '@/components/icons'
7171
import { buildConnectorProviders } from '@/lib/auth/connectors/providers'
7272
import { DEFAULT_MAX_ERROR_BODY_BYTES } from '@/lib/core/utils/stream-limits'
73-
import { getSlackApprovalGatedScopes, OAUTH_PROVIDERS, refreshOAuthToken } from '@/lib/oauth'
73+
import {
74+
getPerRequestOAuthLinkScopes,
75+
getSlackApprovalGatedScopes,
76+
OAUTH_PROVIDERS,
77+
refreshOAuthToken,
78+
} from '@/lib/oauth'
7479
import { REDDIT_USER_AGENT } from '@/tools/reddit/constants'
7580

7681
/**
@@ -138,6 +143,21 @@ describe('Atlassian OAuth connectors', () => {
138143
)
139144
})
140145

146+
describe('Microsoft Dataverse OAuth connector', () => {
147+
it('keeps static connector scopes empty and supplies the canonical legacy grant per request', () => {
148+
const connector = buildConnectorProviders().find(
149+
(candidate) => candidate.providerId === 'microsoft-dataverse'
150+
)
151+
if (!connector) throw new Error('Microsoft Dataverse OAuth connector is not configured')
152+
153+
expect(connector.scopes).toEqual([])
154+
expect(getPerRequestOAuthLinkScopes('microsoft-dataverse')).toEqual(
155+
OAUTH_PROVIDERS.microsoft.services['microsoft-dataverse'].scopes
156+
)
157+
expect(getPerRequestOAuthLinkScopes('microsoft-excel')).toBeUndefined()
158+
})
159+
})
160+
141161
function getBitbucketConnector() {
142162
const connector = buildConnectorProviders().find(
143163
(candidate) => candidate.providerId === 'bitbucket'

0 commit comments

Comments
 (0)