Skip to content

Commit f881696

Browse files
committed
fix(dataverse): request the per-environment OAuth scope
Dataverse's API resource is the customer's own environment host. The provider declared a static `https://dynamics.microsoft.com/user_impersonation`, which is not an Entra Application ID URI — the Dataverse first-party app (00000007-0000-0000-c000-000000000000) publishes `admin.services.crm.dynamics.com`, its regional siblings, and `*.crm.dynamics.com` wildcards, and nothing matching what we sent. Entra rejects it at /authorize, so this integration has never been able to complete consent. There is no tenant-agnostic alternative: the data-API resource *is* the org URL, which is why the scope has to be built per connection rather than declared. A service can now declare `resourceUrl`, and the connect modal collects that host, validates it against the service's allowed domains, and sends `origin + scopeSuffix` in the link request — Better Auth's link route replaces the registered scopes with the body's, which is what makes this possible without a second provider registration. Only Microsoft Dataverse declares it; the other 60 services take an unchanged path. The three initiation surfaces that cannot collect a tenant host fail closed rather than minting a token with no API audience: the desktop hand-off (its scope is id-shaped, validated against ID_PATTERN in a separately released Electron binary, so an installed shell would drop a new field), the Copilot auth-link tool (its schema is generated from the Mothership catalog), and the authorize route the desktop path redirects through. `getDataverseBaseUrl` now shares one host list with the scope builder. Those had drifted into separate copies, and the failure mode is quiet: a new Microsoft sovereign cloud added on one side only produces a token whose audience the tools then refuse to send to. Not fixed here, and the integration stays non-functional until it is: the shared Entra app registration needs the Dynamics CRM delegated permission before any of these scopes can be consented to.
1 parent 3d4e3d2 commit f881696

13 files changed

Lines changed: 330 additions & 56 deletions

File tree

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ vi.mock('@/lib/credentials/access', () => ({
3737

3838
vi.mock('@/lib/oauth/utils', () => ({
3939
getAllOAuthServices: vi.fn(() => [{ providerId: 'google-email', name: 'Gmail' }]),
40+
// Ordinary providers declare no `resourceUrl`, so the resource-scoped guard
41+
// never fires for them and the authorize flow proceeds as before.
42+
getServiceConfigByProviderId: vi.fn(() => ({ providerId: 'google-email', name: 'Gmail' })),
4043
// Real implementation: a credential id matches its service's OAuth id, an
4144
// alternate authorization server, or the family's service-account id.
4245
credentialProviderMatchesService: (

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
88
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
99
import { getCredentialActorContext } from '@/lib/credentials/access'
1010
import { createConnectDraft } from '@/lib/credentials/connect-draft'
11+
import { getServiceConfigByProviderId } from '@/lib/oauth/utils'
1112
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
1213

1314
const logger = createLogger('OAuth2Authorize')
@@ -103,6 +104,21 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
103104

104105
requireConfiguredOAuthClient(providerId)
105106

107+
/**
108+
* A service whose OAuth resource is the customer's own tenant host has no
109+
* static resource scope, and this endpoint has no way to collect one — the
110+
* desktop hand-off carries only id-shaped values. Linking anyway would mint
111+
* a token with no Dataverse audience, which surfaces much later as an opaque
112+
* 401 from the API rather than a problem with the connection.
113+
*/
114+
if (getServiceConfigByProviderId(providerId)?.resourceUrl) {
115+
logger.warn('Blocked OAuth2 authorize for a resource-scoped provider', {
116+
providerId,
117+
userId,
118+
})
119+
return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_resource_url_required`)
120+
}
121+
106122
// Create the draft before initiating the link so it is guaranteed to exist
107123
// (and freshly clocked) when the OAuth callback's `account.create.after`
108124
// hook runs. If this throws, we never start the OAuth flow.

apps/sim/app/desktop/connect/page.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { headers } from 'next/headers'
33
import { redirect } from 'next/navigation'
44
import { auth } from '@/lib/auth'
55
import { getBaseUrl } from '@/lib/core/utils/urls'
6+
import { getServiceConfigByProviderId } from '@/lib/oauth/utils'
67
import { isValidHandoffState, parseLoopbackPort } from '@/app/desktop/auth/validation'
78
import { DesktopHandoffShell } from '@/app/desktop/components/desktop-handoff-shell'
89
import { ConnectLauncher } from '@/app/desktop/connect/connect-launcher'
@@ -34,6 +35,21 @@ function InvalidRequest() {
3435
)
3536
}
3637

38+
/**
39+
* Shown for a service whose OAuth resource is the customer's own tenant host.
40+
* The desktop hand-off carries only id-shaped values, so that host cannot reach
41+
* this page, and linking without it mints a token with no API audience — an
42+
* opaque 401 later rather than a visible failure now.
43+
*/
44+
function ResourceUrlUnsupported() {
45+
return (
46+
<DesktopHandoffShell
47+
title='Connect this account from your browser'
48+
description='This integration needs its environment URL, which the desktop app cannot pass along. Open Sim in your browser and connect it from Settings → Integrations.'
49+
/>
50+
)
51+
}
52+
3753
/**
3854
* Desktop OAuth-connect landing. The desktop app opens this page in the
3955
* system browser with the provider to connect, a one-time state, and the port
@@ -98,6 +114,10 @@ export default async function DesktopConnectPage({ searchParams }: DesktopConnec
98114
// draft — including reconnect rebinding when a credentialId rides along.
99115
// Modal-initiated connects have no workspaceId here (the desktop app already
100116
// created the draft) and use the plain link flow below.
117+
if (getServiceConfigByProviderId(providerId)?.resourceUrl) {
118+
return <ResourceUrlUnsupported />
119+
}
120+
101121
if (workspaceId) {
102122
const authorize = new URL('/api/auth/oauth2/authorize', getBaseUrl())
103123
authorize.searchParams.set('providerId', providerId)

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

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
type OAuthProvider,
2626
parseProvider,
2727
} from '@/lib/oauth'
28+
import { resolveResourceOrigin } from '@/lib/oauth/resource-url'
2829
import { getScopeDescription, getServiceConfigByProviderId } from '@/lib/oauth/utils'
2930
import { useCreateCredentialDraft, useWorkspaceCredentials } from '@/hooks/queries/credentials'
3031
import { useConnectOAuthService } from '@/hooks/queries/oauth/oauth-connections'
@@ -157,6 +158,23 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
157158
const [selectedProviderId, setSelectedProviderId] = useState<string | null>(null)
158159
const providerId = selectedProviderId ?? declaredProviderId
159160

161+
const resourceConfig = getServiceConfigByProviderId(providerId)?.resourceUrl
162+
const [resourceUrl, setResourceUrl] = useState('')
163+
const resolvedResource = resourceConfig
164+
? resolveResourceOrigin(resourceUrl, resourceConfig)
165+
: undefined
166+
/**
167+
* Blocks submit outright: a draft credential is written before the provider
168+
* hand-off, so letting a bad host through would orphan one. The message is
169+
* withheld until the field has content, so an untouched required field is not
170+
* pre-marked as an error.
171+
*/
172+
const resourceIncomplete = Boolean(resolvedResource && !resolvedResource.ok)
173+
const resourceError =
174+
resourceUrl.trim() && resolvedResource && !resolvedResource.ok
175+
? resolvedResource.error
176+
: undefined
177+
160178
const [displayName, setDisplayName] = useState('')
161179
const [description, setDescription] = useState('')
162180
const [validationError, setValidationError] = useState<string | null>(null)
@@ -226,6 +244,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
226244
if (!open) {
227245
prefilled.current = false
228246
setSelectedProviderId(null)
247+
setResourceUrl('')
229248
return
230249
}
231250
if (!isConnect || prefilled.current || credentialsLoading) return
@@ -319,6 +338,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
319338
await connectOAuthService.mutateAsync({
320339
providerId,
321340
callbackURL: callbackURL.toString(),
341+
resourceUrl,
322342
})
323343
handleClose()
324344
} catch (err: unknown) {
@@ -330,8 +350,8 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
330350

331351
const isPending = (isConnect && createDraft.isPending) || connectOAuthService.isPending
332352
const isDisabled = isConnect
333-
? !displayName.trim() || isPending || Boolean(existingCredential)
334-
: isPending
353+
? !displayName.trim() || isPending || Boolean(existingCredential) || resourceIncomplete
354+
: isPending || resourceIncomplete
335355

336356
const displayNameError =
337357
validationError ??
@@ -358,13 +378,30 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
358378
type='dropdown'
359379
title='Environment'
360380
value={providerId}
361-
onChange={setSelectedProviderId}
381+
onChange={(value) => {
382+
setSelectedProviderId(value)
383+
setResourceUrl('')
384+
}}
362385
options={authServerOptions}
363386
align='start'
364387
hint={authServerHint}
365388
/>
366389
)}
367390

391+
{resourceConfig && (
392+
<ChipModalField
393+
type='input'
394+
title={resourceConfig.title}
395+
value={resourceUrl}
396+
onChange={setResourceUrl}
397+
placeholder={resourceConfig.placeholder}
398+
autoComplete='off'
399+
required
400+
hint={resourceConfig.hint}
401+
error={resourceError}
402+
/>
403+
)}
404+
368405
{isConnect && (
369406
<ChipModalField
370407
type='input'

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

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import {
1212
import { client } from '@/lib/auth/auth-client'
1313
import { getDesktopBridge } from '@/lib/desktop'
1414
import { OAUTH_PROVIDERS, type OAuthServiceConfig } from '@/lib/oauth'
15+
import { resolveResourceOrigin } from '@/lib/oauth/resource-url'
16+
import type { OAuthResourceUrlConfig } from '@/lib/oauth/types'
17+
import { getServiceConfigByProviderId } from '@/lib/oauth/utils'
1518

1619
const logger = createLogger('OAuthConnectionsQuery')
1720

@@ -140,6 +143,34 @@ export function useOAuthConnections() {
140143
interface ConnectServiceParams {
141144
providerId: string
142145
callbackURL: string
146+
/**
147+
* Tenant host for a service whose OAuth resource is per-customer, as declared
148+
* by {@link OAuthServiceConfig.resourceUrl}. Ignored by every other provider.
149+
*/
150+
resourceUrl?: string
151+
}
152+
153+
/**
154+
* Builds the scope list for a service whose OAuth resource is per-tenant.
155+
*
156+
* Better Auth's link route *replaces* the provider's registered scopes with the
157+
* ones in the request body, so this returns the full list — the static scopes
158+
* plus the resource scope naming the validated origin.
159+
*
160+
* @throws {Error} when the URL is missing or is not one of the service's hosts.
161+
* Reaching here with a bad value means the modal's own check was bypassed, so
162+
* failing is right; the alternative is an opaque error from the provider.
163+
*/
164+
function resolveConnectScopes(
165+
service: OAuthServiceConfig,
166+
resourceConfig: OAuthResourceUrlConfig,
167+
resourceUrl: string | undefined
168+
): string[] {
169+
const resolved = resolveResourceOrigin(resourceUrl, resourceConfig)
170+
if (!resolved.ok) {
171+
throw new Error(resolved.error)
172+
}
173+
return [...service.scopes, `${resolved.origin}${resourceConfig.scopeSuffix}`]
143174
}
144175

145176
/**
@@ -150,7 +181,9 @@ export function useConnectOAuthService() {
150181
const queryClient = useQueryClient()
151182

152183
return useMutation({
153-
mutationFn: async ({ providerId, callbackURL }: ConnectServiceParams) => {
184+
mutationFn: async ({ providerId, callbackURL, resourceUrl }: ConnectServiceParams) => {
185+
const service = getServiceConfigByProviderId(providerId)
186+
const resourceConfig = service?.resourceUrl
154187
if (providerId === 'trello') {
155188
const returnUrl = encodeURIComponent(callbackURL)
156189
window.location.href = `/api/auth/trello/authorize?returnUrl=${returnUrl}`
@@ -177,6 +210,19 @@ export function useConnectOAuthService() {
177210
// which refreshes caches and shows the connected toast.
178211
const desktopBridge = getDesktopBridge()
179212
if (desktopBridge?.beginOAuthConnect) {
213+
/**
214+
* The desktop hand-off carries only an id-shaped scope
215+
* (`DesktopOAuthConnectScope`), validated against `ID_PATTERN` in the
216+
* Electron main process, and ships as a separately released binary — so
217+
* an installed shell would silently drop a tenant URL even after the
218+
* bridge contract gained one. Fail with a route the user can actually
219+
* take rather than starting a flow that cannot request the right scope.
220+
*/
221+
if (resourceConfig) {
222+
throw new Error(
223+
`Connecting ${service?.name ?? providerId} is not supported in the desktop app yet — connect it from Sim in your browser.`
224+
)
225+
}
180226
const opened = await desktopBridge.beginOAuthConnect(providerId)
181227
if (!opened) {
182228
throw new Error('Could not open your browser to connect this account.')
@@ -187,6 +233,9 @@ export function useConnectOAuthService() {
187233
await client.oauth2.link({
188234
providerId,
189235
callbackURL,
236+
...(resourceConfig && service
237+
? { scopes: resolveConnectScopes(service, resourceConfig, resourceUrl) }
238+
: {}),
190239
})
191240

192241
return { success: true }

apps/sim/lib/copilot/tools/handlers/oauth.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,19 @@ async function generateOAuthLink(
206206
? `${baseUrl}/workspace/${workspaceId}/chat/${chatId}`
207207
: `${baseUrl}/workspace/${workspaceId}`
208208

209+
/**
210+
* A service whose OAuth resource is the customer's own tenant host needs that
211+
* URL before the authorization request is built, and this tool has nowhere to
212+
* take one from — its schema is generated from the Mothership catalog. An
213+
* authorize link without it would request the wrong resource and fail at the
214+
* provider.
215+
*/
216+
if (matched.resourceUrl) {
217+
throw new Error(
218+
`${serviceName} needs its environment URL to connect, which this tool cannot supply. Ask the user to connect ${serviceName} from Settings → Integrations.`
219+
)
220+
}
221+
209222
if (providerId === 'trello') {
210223
const authorizeUrl = new URL(`${baseUrl}/api/auth/trello/authorize`)
211224
authorizeUrl.searchParams.set('returnUrl', callbackURL)

apps/sim/lib/oauth/dataverse.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { OAuthResourceUrlConfig } from '@/lib/oauth/types'
2+
3+
/**
4+
* The one description of a Dataverse environment host.
5+
*
6+
* Two places depend on it and must not drift: the OAuth scope names this origin
7+
* as the token's audience, and every Web API request is pinned to it. If
8+
* Microsoft adds a sovereign cloud and only one side learns about it, the result
9+
* is a token whose audience the tool then refuses to send to.
10+
*
11+
* Suffixes are the registrable domains Microsoft serves environments from —
12+
* commercial and regional clouds (`*.crm[N].dynamics.com`), China (21Vianet),
13+
* US Government and DoD, and the legacy German cloud. Matching the registrable
14+
* domain rather than each regional `crmN` prefix keeps new Microsoft regions
15+
* working without a code change.
16+
*/
17+
export const DATAVERSE_RESOURCE_URL: OAuthResourceUrlConfig = {
18+
title: 'Environment URL',
19+
placeholder: 'https://myorg.crm.dynamics.com',
20+
hint: 'Find this in Power Platform admin center under your environment.',
21+
allowedHostSuffixes: [
22+
'.dynamics.com',
23+
'.dynamics.cn',
24+
'.dynamics.de',
25+
'.microsoftdynamics.us',
26+
'.appsplatform.us',
27+
],
28+
scopeSuffix: '/user_impersonation',
29+
}

apps/sim/lib/oauth/oauth.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import {
7171
DEFAULT_MAX_ERROR_BODY_BYTES,
7272
readResponseTextWithLimit,
7373
} from '@/lib/core/utils/stream-limits'
74+
import { DATAVERSE_RESOURCE_URL } from '@/lib/oauth/dataverse'
7475
import { parseInstagramLongLivedToken } from '@/lib/oauth/instagram'
7576
import {
7677
SALESFORCE_ADDITIONAL_PROVIDER_IDS,
@@ -348,13 +349,15 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
348349
providerId: 'microsoft-dataverse',
349350
icon: MicrosoftDataverseIcon,
350351
baseProviderIcon: MicrosoftIcon,
351-
scopes: [
352-
'openid',
353-
'profile',
354-
'email',
355-
'https://dynamics.microsoft.com/user_impersonation',
356-
'offline_access',
357-
],
352+
/**
353+
* Only the OIDC scopes are static. Dataverse's API resource is the
354+
* customer's own environment host — the Dataverse first-party app
355+
* publishes `*.crm.dynamics.com` and its regional siblings, never a
356+
* tenant-agnostic URI — so the resource scope is built per connection
357+
* from {@link resourceUrl} instead of being declared here.
358+
*/
359+
scopes: ['openid', 'profile', 'email', 'offline_access'],
360+
resourceUrl: DATAVERSE_RESOURCE_URL,
358361
},
359362
'microsoft-excel': {
360363
name: 'Microsoft Excel',
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { resolveResourceOrigin } from '@/lib/oauth/resource-url'
6+
import type { OAuthResourceUrlConfig } from '@/lib/oauth/types'
7+
8+
const config: OAuthResourceUrlConfig = {
9+
title: 'Environment URL',
10+
placeholder: 'https://myorg.crm.dynamics.com',
11+
allowedHostSuffixes: ['.dynamics.com', '.microsoftdynamics.us'],
12+
scopeSuffix: '/user_impersonation',
13+
}
14+
15+
describe('resolveResourceOrigin', () => {
16+
it('accepts an allowed host and reduces it to a bare origin', () => {
17+
const result = resolveResourceOrigin('https://myorg.crm.dynamics.com/api/data/v9.2/', config)
18+
expect(result).toEqual({ ok: true, origin: 'https://myorg.crm.dynamics.com' })
19+
})
20+
21+
it('assumes https when the scheme is omitted, which is how users paste a host', () => {
22+
const result = resolveResourceOrigin('myorg.crm4.dynamics.com', config)
23+
expect(result).toEqual({ ok: true, origin: 'https://myorg.crm4.dynamics.com' })
24+
})
25+
26+
it('matches on the registrable domain so new provider regions keep working', () => {
27+
expect(resolveResourceOrigin('https://org.crm17.dynamics.com', config).ok).toBe(true)
28+
expect(resolveResourceOrigin('https://org.crm.microsoftdynamics.us', config).ok).toBe(true)
29+
})
30+
31+
it('rejects a host outside the allow list, which would set the token audience', () => {
32+
const result = resolveResourceOrigin('https://attacker.example', config)
33+
expect(result.ok).toBe(false)
34+
})
35+
36+
it('rejects a lookalike host that only contains an allowed suffix mid-string', () => {
37+
expect(resolveResourceOrigin('https://dynamics.com.attacker.example', config).ok).toBe(false)
38+
})
39+
40+
it('rejects plaintext http so the resource cannot be downgraded', () => {
41+
expect(resolveResourceOrigin('http://myorg.crm.dynamics.com', config).ok).toBe(false)
42+
})
43+
44+
it('rejects embedded credentials', () => {
45+
expect(resolveResourceOrigin('https://u:p@myorg.crm.dynamics.com', config).ok).toBe(false)
46+
})
47+
48+
it('rejects an empty value rather than building a scope from nothing', () => {
49+
expect(resolveResourceOrigin(' ', config).ok).toBe(false)
50+
expect(resolveResourceOrigin(undefined, config).ok).toBe(false)
51+
})
52+
})

0 commit comments

Comments
 (0)