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 54f49a5e29f..0abecdf5b10 100644
--- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts
+++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts
@@ -37,6 +37,9 @@ vi.mock('@/lib/credentials/access', () => ({
vi.mock('@/lib/oauth/utils', () => ({
getAllOAuthServices: vi.fn(() => [{ providerId: 'google-email', name: 'Gmail' }]),
+ // Ordinary providers declare no `resourceUrl`, so the resource-scoped guard
+ // never fires for them and the authorize flow proceeds as before.
+ getServiceConfigByProviderId: vi.fn(() => ({ providerId: 'google-email', name: 'Gmail' })),
// Real implementation: a credential id matches its service's OAuth id, an
// alternate authorization server, or the family's service-account id.
credentialProviderMatchesService: (
diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts
index 063de2ca015..2b7aecee80c 100644
--- a/apps/sim/app/api/auth/oauth2/authorize/route.ts
+++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts
@@ -8,6 +8,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getCredentialActorContext } from '@/lib/credentials/access'
import { createConnectDraft } from '@/lib/credentials/connect-draft'
+import { getServiceConfigByProviderId } from '@/lib/oauth/utils'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('OAuth2Authorize')
@@ -103,6 +104,21 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
requireConfiguredOAuthClient(providerId)
+ /**
+ * A service whose OAuth resource is the customer's own tenant host has no
+ * static resource scope, and this endpoint has no way to collect one — the
+ * desktop hand-off carries only id-shaped values. Linking anyway would mint
+ * a token with no Dataverse audience, which surfaces much later as an opaque
+ * 401 from the API rather than a problem with the connection.
+ */
+ if (getServiceConfigByProviderId(providerId)?.resourceUrl) {
+ logger.warn('Blocked OAuth2 authorize for a resource-scoped provider', {
+ providerId,
+ userId,
+ })
+ return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_resource_url_required`)
+ }
+
// Create the draft before initiating the link so it is guaranteed to exist
// (and freshly clocked) when the OAuth callback's `account.create.after`
// hook runs. If this throws, we never start the OAuth flow.
diff --git a/apps/sim/app/desktop/connect/page.tsx b/apps/sim/app/desktop/connect/page.tsx
index 8207e2c3acf..2fdae146c20 100644
--- a/apps/sim/app/desktop/connect/page.tsx
+++ b/apps/sim/app/desktop/connect/page.tsx
@@ -3,6 +3,7 @@ import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { auth } from '@/lib/auth'
import { getBaseUrl } from '@/lib/core/utils/urls'
+import { getServiceConfigByProviderId } from '@/lib/oauth/utils'
import { isValidHandoffState, parseLoopbackPort } from '@/app/desktop/auth/validation'
import { DesktopHandoffShell } from '@/app/desktop/components/desktop-handoff-shell'
import { ConnectLauncher } from '@/app/desktop/connect/connect-launcher'
@@ -34,6 +35,21 @@ function InvalidRequest() {
)
}
+/**
+ * Shown for a service whose OAuth resource is the customer's own tenant host.
+ * The desktop hand-off carries only id-shaped values, so that host cannot reach
+ * this page, and linking without it mints a token with no API audience — an
+ * opaque 401 later rather than a visible failure now.
+ */
+function ResourceUrlUnsupported() {
+ return (
+
+ )
+}
+
/**
* Desktop OAuth-connect landing. The desktop app opens this page in the
* 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
// draft — including reconnect rebinding when a credentialId rides along.
// Modal-initiated connects have no workspaceId here (the desktop app already
// created the draft) and use the plain link flow below.
+ if (getServiceConfigByProviderId(providerId)?.resourceUrl) {
+ return
+ }
+
if (workspaceId) {
const authorize = new URL('/api/auth/oauth2/authorize', getBaseUrl())
authorize.searchParams.set('providerId', providerId)
diff --git a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx
index a5bd8cdf51a..235ca186ef5 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx
@@ -25,6 +25,7 @@ import {
type OAuthProvider,
parseProvider,
} from '@/lib/oauth'
+import { resolveResourceOrigin } from '@/lib/oauth/resource-url'
import { getScopeDescription, getServiceConfigByProviderId } from '@/lib/oauth/utils'
import { useCreateCredentialDraft, useWorkspaceCredentials } from '@/hooks/queries/credentials'
import { useConnectOAuthService } from '@/hooks/queries/oauth/oauth-connections'
@@ -157,6 +158,23 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
const [selectedProviderId, setSelectedProviderId] = useState(null)
const providerId = selectedProviderId ?? declaredProviderId
+ const resourceConfig = getServiceConfigByProviderId(providerId)?.resourceUrl
+ const [resourceUrl, setResourceUrl] = useState('')
+ const resolvedResource = resourceConfig
+ ? resolveResourceOrigin(resourceUrl, resourceConfig)
+ : undefined
+ /**
+ * Blocks submit outright: a draft credential is written before the provider
+ * hand-off, so letting a bad host through would orphan one. The message is
+ * withheld until the field has content, so an untouched required field is not
+ * pre-marked as an error.
+ */
+ const resourceIncomplete = Boolean(resolvedResource && !resolvedResource.ok)
+ const resourceError =
+ resourceUrl.trim() && resolvedResource && !resolvedResource.ok
+ ? resolvedResource.error
+ : undefined
+
const [displayName, setDisplayName] = useState('')
const [description, setDescription] = useState('')
const [validationError, setValidationError] = useState(null)
@@ -226,6 +244,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
if (!open) {
prefilled.current = false
setSelectedProviderId(null)
+ setResourceUrl('')
return
}
if (!isConnect || prefilled.current || credentialsLoading) return
@@ -319,6 +338,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
await connectOAuthService.mutateAsync({
providerId,
callbackURL: callbackURL.toString(),
+ resourceUrl,
})
handleClose()
} catch (err: unknown) {
@@ -330,8 +350,8 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
const isPending = (isConnect && createDraft.isPending) || connectOAuthService.isPending
const isDisabled = isConnect
- ? !displayName.trim() || isPending || Boolean(existingCredential)
- : isPending
+ ? !displayName.trim() || isPending || Boolean(existingCredential) || resourceIncomplete
+ : isPending || resourceIncomplete
const displayNameError =
validationError ??
@@ -358,13 +378,30 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
type='dropdown'
title='Environment'
value={providerId}
- onChange={setSelectedProviderId}
+ onChange={(value) => {
+ setSelectedProviderId(value)
+ setResourceUrl('')
+ }}
options={authServerOptions}
align='start'
hint={authServerHint}
/>
)}
+ {resourceConfig && (
+
+ )}
+
{isConnect && (
new Map(oauthConnections.map((service) => [service.providerId, service.name])),
[oauthConnections]
@@ -113,6 +137,29 @@ export function ConnectedCredentialDetail({
const handleReconnectOAuth = async () => {
if (!credential || credential.type !== 'oauth' || !credential.providerId || !workspaceId) return
try {
+ /**
+ * A reconnect must return to the environment the credential already
+ * belongs to, so the origin is read back off this credential's own granted
+ * scopes rather than asked for again.
+ *
+ * Resolved before the draft is written so a failure leaves nothing behind.
+ */
+ const resourceConfig = getServiceConfigByProviderId(credential.providerId)?.resourceUrl
+ let resourceUrl: string | undefined
+ if (resourceConfig) {
+ if (scopesUnavailable) {
+ throw new Error(
+ `Couldn't read this credential's ${resourceConfig.title}. Try again in a moment.`
+ )
+ }
+ resourceUrl = findGrantedResourceOrigin(grantedScopes, resourceConfig)
+ if (!resourceUrl) {
+ throw new Error(
+ `This credential is not bound to an ${resourceConfig.title}. Disconnect it here, then connect the account again.`
+ )
+ }
+ }
+
await createDraft.mutateAsync({
workspaceId,
providerId: credential.providerId,
@@ -137,6 +184,7 @@ export function ConnectedCredentialDetail({
await connectOAuthService.mutateAsync({
providerId: credential.providerId,
callbackURL: window.location.href,
+ resourceUrl,
})
} catch (error: unknown) {
toast.error("Couldn't start reconnect", {
@@ -196,7 +244,7 @@ export function ConnectedCredentialDetail({
? () => setReconnectOpen(true)
: handleReconnectOAuth
}
- disabled={connectOAuthService.isPending}
+ disabled={connectOAuthService.isPending || (isResourceScoped && scopesLoading)}
leftIcon={display?.icon ?? undefined}
>
Reconnect
diff --git a/apps/sim/hooks/queries/oauth/oauth-connections.ts b/apps/sim/hooks/queries/oauth/oauth-connections.ts
index 25338567464..bd61c2405f4 100644
--- a/apps/sim/hooks/queries/oauth/oauth-connections.ts
+++ b/apps/sim/hooks/queries/oauth/oauth-connections.ts
@@ -12,6 +12,9 @@ import {
import { client } from '@/lib/auth/auth-client'
import { getDesktopBridge } from '@/lib/desktop'
import { OAUTH_PROVIDERS, type OAuthServiceConfig } from '@/lib/oauth'
+import { resolveResourceOrigin } from '@/lib/oauth/resource-url'
+import type { OAuthResourceUrlConfig } from '@/lib/oauth/types'
+import { getServiceConfigByProviderId } from '@/lib/oauth/utils'
const logger = createLogger('OAuthConnectionsQuery')
@@ -140,6 +143,34 @@ export function useOAuthConnections() {
interface ConnectServiceParams {
providerId: string
callbackURL: string
+ /**
+ * Tenant host for a service whose OAuth resource is per-customer, as declared
+ * by {@link OAuthServiceConfig.resourceUrl}. Ignored by every other provider.
+ */
+ resourceUrl?: string
+}
+
+/**
+ * Builds the scope list for a service whose OAuth resource is per-tenant.
+ *
+ * Better Auth's link route *replaces* the provider's registered scopes with the
+ * ones in the request body, so this returns the full list — the static scopes
+ * plus the resource scope naming the validated origin.
+ *
+ * @throws {Error} when the URL is missing or is not one of the service's hosts.
+ * Reaching here with a bad value means the modal's own check was bypassed, so
+ * failing is right; the alternative is an opaque error from the provider.
+ */
+function resolveConnectScopes(
+ service: OAuthServiceConfig,
+ resourceConfig: OAuthResourceUrlConfig,
+ resourceUrl: string | undefined
+): string[] {
+ const resolved = resolveResourceOrigin(resourceUrl, resourceConfig)
+ if (!resolved.ok) {
+ throw new Error(resolved.error)
+ }
+ return [...service.scopes, `${resolved.origin}${resourceConfig.scopeSuffix}`]
}
/**
@@ -150,7 +181,9 @@ export function useConnectOAuthService() {
const queryClient = useQueryClient()
return useMutation({
- mutationFn: async ({ providerId, callbackURL }: ConnectServiceParams) => {
+ mutationFn: async ({ providerId, callbackURL, resourceUrl }: ConnectServiceParams) => {
+ const service = getServiceConfigByProviderId(providerId)
+ const resourceConfig = service?.resourceUrl
if (providerId === 'trello') {
const returnUrl = encodeURIComponent(callbackURL)
window.location.href = `/api/auth/trello/authorize?returnUrl=${returnUrl}`
@@ -177,6 +210,19 @@ export function useConnectOAuthService() {
// which refreshes caches and shows the connected toast.
const desktopBridge = getDesktopBridge()
if (desktopBridge?.beginOAuthConnect) {
+ /**
+ * The desktop hand-off carries only an id-shaped scope
+ * (`DesktopOAuthConnectScope`), validated against `ID_PATTERN` in the
+ * Electron main process, and ships as a separately released binary — so
+ * an installed shell would silently drop a tenant URL even after the
+ * bridge contract gained one. Fail with a route the user can actually
+ * take rather than starting a flow that cannot request the right scope.
+ */
+ if (resourceConfig) {
+ throw new Error(
+ `Connecting ${service?.name ?? providerId} is not supported in the desktop app yet — connect it from Sim in your browser.`
+ )
+ }
const opened = await desktopBridge.beginOAuthConnect(providerId)
if (!opened) {
throw new Error('Could not open your browser to connect this account.')
@@ -187,6 +233,9 @@ export function useConnectOAuthService() {
await client.oauth2.link({
providerId,
callbackURL,
+ ...(resourceConfig && service
+ ? { scopes: resolveConnectScopes(service, resourceConfig, resourceUrl) }
+ : {}),
})
return { success: true }
diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts
index c9c4951efd2..52bdd1f637d 100644
--- a/apps/sim/lib/api/contracts/oauth-connections.ts
+++ b/apps/sim/lib/api/contracts/oauth-connections.ts
@@ -97,6 +97,12 @@ const oauthTokenResponseSchema = z.object({
instanceUrl: z.string().optional(),
/** Zoho Desk — the data-center-scoped Desk REST base for this credential. */
apiDomain: z.string().optional(),
+ /**
+ * A service whose OAuth resource is the customer's own tenant host — the
+ * origin this credential's token is actually an audience for, recovered from
+ * its granted scope. Dataverse is the one such service today.
+ */
+ resourceUrl: z.string().optional(),
cloudId: z.string().optional(),
domain: z.string().optional(),
authStyle: z.enum(['x-api-token']).optional(),
diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.ts b/apps/sim/lib/copilot/tools/handlers/oauth.ts
index 5549efacd10..c8fcf46ebeb 100644
--- a/apps/sim/lib/copilot/tools/handlers/oauth.ts
+++ b/apps/sim/lib/copilot/tools/handlers/oauth.ts
@@ -206,6 +206,19 @@ async function generateOAuthLink(
? `${baseUrl}/workspace/${workspaceId}/chat/${chatId}`
: `${baseUrl}/workspace/${workspaceId}`
+ /**
+ * A service whose OAuth resource is the customer's own tenant host needs that
+ * URL before the authorization request is built, and this tool has nowhere to
+ * take one from — its schema is generated from the Mothership catalog. An
+ * authorize link without it would request the wrong resource and fail at the
+ * provider.
+ */
+ if (matched.resourceUrl) {
+ throw new Error(
+ `${serviceName} needs its environment URL to connect, which this tool cannot supply. Ask the user to connect ${serviceName} from Settings → Integrations.`
+ )
+ }
+
if (providerId === 'trello') {
const authorizeUrl = new URL(`${baseUrl}/api/auth/trello/authorize`)
authorizeUrl.searchParams.set('returnUrl', callbackURL)
diff --git a/apps/sim/lib/oauth/dataverse.ts b/apps/sim/lib/oauth/dataverse.ts
new file mode 100644
index 00000000000..a7d164f919e
--- /dev/null
+++ b/apps/sim/lib/oauth/dataverse.ts
@@ -0,0 +1,29 @@
+import type { OAuthResourceUrlConfig } from '@/lib/oauth/types'
+
+/**
+ * The one description of a Dataverse environment host.
+ *
+ * Two places depend on it and must not drift: the OAuth scope names this origin
+ * as the token's audience, and every Web API request is pinned to it. If
+ * Microsoft adds a sovereign cloud and only one side learns about it, the result
+ * is a token whose audience the tool then refuses to send to.
+ *
+ * Suffixes are the registrable domains Microsoft serves environments from —
+ * commercial and regional clouds (`*.crm[N].dynamics.com`), China (21Vianet),
+ * US Government and DoD, and the legacy German cloud. Matching the registrable
+ * domain rather than each regional `crmN` prefix keeps new Microsoft regions
+ * working without a code change.
+ */
+export const DATAVERSE_RESOURCE_URL: OAuthResourceUrlConfig = {
+ title: 'Environment URL',
+ placeholder: 'https://myorg.crm.dynamics.com',
+ hint: 'Find this in Power Platform admin center under your environment.',
+ allowedHostSuffixes: [
+ '.dynamics.com',
+ '.dynamics.cn',
+ '.dynamics.de',
+ '.microsoftdynamics.us',
+ '.appsplatform.us',
+ ],
+ scopeSuffix: '/user_impersonation',
+}
diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts
index 0ba237f5fa2..91b3e018a42 100644
--- a/apps/sim/lib/oauth/oauth.ts
+++ b/apps/sim/lib/oauth/oauth.ts
@@ -71,6 +71,7 @@ import {
DEFAULT_MAX_ERROR_BODY_BYTES,
readResponseTextWithLimit,
} from '@/lib/core/utils/stream-limits'
+import { DATAVERSE_RESOURCE_URL } from '@/lib/oauth/dataverse'
import { parseInstagramLongLivedToken } from '@/lib/oauth/instagram'
import {
SALESFORCE_ADDITIONAL_PROVIDER_IDS,
@@ -348,13 +349,15 @@ export const OAUTH_PROVIDERS: Record = {
providerId: 'microsoft-dataverse',
icon: MicrosoftDataverseIcon,
baseProviderIcon: MicrosoftIcon,
- scopes: [
- 'openid',
- 'profile',
- 'email',
- 'https://dynamics.microsoft.com/user_impersonation',
- 'offline_access',
- ],
+ /**
+ * Only the OIDC scopes are static. Dataverse's API resource is the
+ * customer's own environment host — the Dataverse first-party app
+ * publishes `*.crm.dynamics.com` and its regional siblings, never a
+ * tenant-agnostic URI — so the resource scope is built per connection
+ * from {@link resourceUrl} instead of being declared here.
+ */
+ scopes: ['openid', 'profile', 'email', 'offline_access'],
+ resourceUrl: DATAVERSE_RESOURCE_URL,
},
'microsoft-excel': {
name: 'Microsoft Excel',
diff --git a/apps/sim/lib/oauth/resource-url.test.ts b/apps/sim/lib/oauth/resource-url.test.ts
new file mode 100644
index 00000000000..e39e4c4e02f
--- /dev/null
+++ b/apps/sim/lib/oauth/resource-url.test.ts
@@ -0,0 +1,86 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import { findGrantedResourceOrigin, resolveResourceOrigin } from '@/lib/oauth/resource-url'
+import type { OAuthResourceUrlConfig } from '@/lib/oauth/types'
+
+const config: OAuthResourceUrlConfig = {
+ title: 'Environment URL',
+ placeholder: 'https://myorg.crm.dynamics.com',
+ allowedHostSuffixes: ['.dynamics.com', '.microsoftdynamics.us'],
+ scopeSuffix: '/user_impersonation',
+}
+
+describe('resolveResourceOrigin', () => {
+ it('accepts an allowed host and reduces it to a bare origin', () => {
+ const result = resolveResourceOrigin('https://myorg.crm.dynamics.com/api/data/v9.2/', config)
+ expect(result).toEqual({ ok: true, origin: 'https://myorg.crm.dynamics.com' })
+ })
+
+ it('assumes https when the scheme is omitted, which is how users paste a host', () => {
+ const result = resolveResourceOrigin('myorg.crm4.dynamics.com', config)
+ expect(result).toEqual({ ok: true, origin: 'https://myorg.crm4.dynamics.com' })
+ })
+
+ it('matches on the registrable domain so new provider regions keep working', () => {
+ expect(resolveResourceOrigin('https://org.crm17.dynamics.com', config).ok).toBe(true)
+ expect(resolveResourceOrigin('https://org.crm.microsoftdynamics.us', config).ok).toBe(true)
+ })
+
+ it('rejects a host outside the allow list, which would set the token audience', () => {
+ const result = resolveResourceOrigin('https://attacker.example', config)
+ expect(result.ok).toBe(false)
+ })
+
+ it('rejects a lookalike host that only contains an allowed suffix mid-string', () => {
+ expect(resolveResourceOrigin('https://dynamics.com.attacker.example', config).ok).toBe(false)
+ })
+
+ it('rejects plaintext http so the resource cannot be downgraded', () => {
+ expect(resolveResourceOrigin('http://myorg.crm.dynamics.com', config).ok).toBe(false)
+ })
+
+ it('rejects an explicit port, which no provider serves its resource on', () => {
+ expect(resolveResourceOrigin('https://myorg.crm.dynamics.com:8443', config).ok).toBe(false)
+ })
+
+ it('accepts the default https port, which URL drops from the origin', () => {
+ const result = resolveResourceOrigin('https://myorg.crm.dynamics.com:443', config)
+ expect(result).toEqual({ ok: true, origin: 'https://myorg.crm.dynamics.com' })
+ })
+
+ it('rejects embedded credentials', () => {
+ expect(resolveResourceOrigin('https://u:p@myorg.crm.dynamics.com', config).ok).toBe(false)
+ })
+
+ it('rejects an empty value rather than building a scope from nothing', () => {
+ expect(resolveResourceOrigin(' ', config).ok).toBe(false)
+ expect(resolveResourceOrigin(undefined, config).ok).toBe(false)
+ })
+})
+
+describe('findGrantedResourceOrigin', () => {
+ it('recovers the origin from the granted resource scope', () => {
+ const scopes = ['openid', 'offline_access', 'https://myorg.crm.dynamics.com/user_impersonation']
+ expect(findGrantedResourceOrigin(scopes, config)).toBe('https://myorg.crm.dynamics.com')
+ })
+
+ it('returns undefined when no scope names a resource, which marks a credential unusable', () => {
+ expect(findGrantedResourceOrigin(['openid', 'offline_access'], config)).toBeUndefined()
+ expect(findGrantedResourceOrigin([], config)).toBeUndefined()
+ expect(findGrantedResourceOrigin(undefined, config)).toBeUndefined()
+ })
+
+ it('ignores a resource scope whose host is not allowed', () => {
+ expect(
+ findGrantedResourceOrigin(['https://attacker.example/user_impersonation'], config)
+ ).toBeUndefined()
+ })
+
+ it('ignores a scope that merely contains the suffix mid-string', () => {
+ expect(
+ findGrantedResourceOrigin(['https://myorg.crm.dynamics.com/user_impersonation.other'], config)
+ ).toBeUndefined()
+ })
+})
diff --git a/apps/sim/lib/oauth/resource-url.ts b/apps/sim/lib/oauth/resource-url.ts
new file mode 100644
index 00000000000..cfcd394c7d3
--- /dev/null
+++ b/apps/sim/lib/oauth/resource-url.ts
@@ -0,0 +1,86 @@
+import type { OAuthResourceUrlConfig } from '@/lib/oauth/types'
+
+/**
+ * Outcome of validating a user-supplied resource URL. Modeled as a result
+ * rather than a throw because the connect modal validates on every keystroke,
+ * where an exception would be control flow rather than an error.
+ */
+export type ResourceOriginResult = { ok: true; origin: string } | { ok: false; error: string }
+
+/**
+ * Normalizes a user-supplied resource URL to a bare `https://host` origin and
+ * checks it against the service's allowed hosts.
+ *
+ * The origin ends up inside an OAuth scope, so an unchecked value would let a
+ * connect attempt request a token audience of the submitter's choosing. Hosts
+ * are matched on the registrable domain rather than each regional prefix, so a
+ * new provider region keeps working without a code change.
+ */
+export function resolveResourceOrigin(
+ raw: string | undefined,
+ config: OAuthResourceUrlConfig
+): ResourceOriginResult {
+ const value = (raw ?? '').trim()
+ if (!value) {
+ return { ok: false, error: `${config.title} is required` }
+ }
+
+ const withScheme = /^https?:\/\//i.test(value) ? value : `https://${value}`
+
+ let parsed: URL
+ try {
+ parsed = new URL(withScheme)
+ } catch {
+ return { ok: false, error: `${config.title} must be a valid URL, e.g. ${config.placeholder}` }
+ }
+
+ if (parsed.protocol !== 'https:') {
+ return { ok: false, error: `${config.title} must use https` }
+ }
+ if (parsed.username || parsed.password) {
+ return { ok: false, error: `${config.title} must not contain credentials` }
+ }
+ /**
+ * `URL` drops the port only when it is the scheme default, so anything left
+ * here is explicit. The origin becomes an OAuth audience and the base for
+ * every API request, and providers publish their resource on the default
+ * port — a port-qualified origin would be a resource nobody serves.
+ */
+ if (parsed.port) {
+ return { ok: false, error: `${config.title} must not include a port` }
+ }
+
+ /**
+ * The allowlist is the whole security control here: no private address,
+ * loopback host, or internal name can end with one of these public registrable
+ * domains, so a separate SSRF check would add nothing.
+ */
+ const hostname = parsed.hostname.toLowerCase()
+ if (!config.allowedHostSuffixes.some((suffix) => hostname.endsWith(suffix))) {
+ return { ok: false, error: `${config.title} must be a valid URL, e.g. ${config.placeholder}` }
+ }
+
+ return { ok: true, origin: parsed.origin }
+}
+
+/**
+ * Recovers the tenant origin a credential's token is an audience for, by finding
+ * the resource scope among the scopes actually granted.
+ *
+ * The origin is already recorded there by the connect flow, so it is read back
+ * rather than stored a second time or asked of the user again. This is also the
+ * completeness check for such a credential: a resource-scoped service whose
+ * granted scopes name no valid host holds a token no API will accept, which is
+ * otherwise invisible until a request returns 401.
+ */
+export function findGrantedResourceOrigin(
+ scopes: readonly string[] | undefined,
+ config: OAuthResourceUrlConfig
+): string | undefined {
+ for (const scope of scopes ?? []) {
+ if (!scope.endsWith(config.scopeSuffix)) continue
+ const resolved = resolveResourceOrigin(scope.slice(0, -config.scopeSuffix.length), config)
+ if (resolved.ok) return resolved.origin
+ }
+ return undefined
+}
diff --git a/apps/sim/lib/oauth/token-resolution.ts b/apps/sim/lib/oauth/token-resolution.ts
index 4ee18823f8b..700cf4f50dc 100644
--- a/apps/sim/lib/oauth/token-resolution.ts
+++ b/apps/sim/lib/oauth/token-resolution.ts
@@ -13,7 +13,9 @@ import {
resolveOAuthAccountId,
resolveServiceAccountToken,
} from '@/lib/oauth/credential-service'
+import { findGrantedResourceOrigin } from '@/lib/oauth/resource-url'
import { extractSalesforceInstanceUrl, isSalesforceOAuthProviderId } from '@/lib/oauth/salesforce'
+import { getServiceConfigByProviderId } from '@/lib/oauth/utils'
import { captureServerEvent } from '@/lib/posthog/server'
import { extractZohoDeskBaseFromScope } from '@/tools/zoho_desk/host-allowlist'
@@ -107,11 +109,22 @@ function buildOAuthTokenPayload(
apiDomain = extractZohoDeskBaseFromScope(credential.scope)
}
+ /**
+ * The tenant host a resource-scoped credential is bound to. Unlike the two
+ * above it is not smuggled into the scope column — the OAuth scope names the
+ * origin by construction, so the granted scope already carries it.
+ */
+ const resourceConfig = getServiceConfigByProviderId(credential.providerId)?.resourceUrl
+ const resourceUrl = resourceConfig
+ ? findGrantedResourceOrigin(credential.scope?.split(/[\s,]+/), resourceConfig)
+ : undefined
+
return {
accessToken,
idToken: credential.idToken || undefined,
...(instanceUrl && { instanceUrl }),
...(apiDomain && { apiDomain }),
+ ...(resourceUrl && { resourceUrl }),
}
}
@@ -143,7 +156,30 @@ export async function completeOAuthCredentialToken(params: {
})
}
- return { ok: true, token: buildOAuthTokenPayload(credential, accessToken) }
+ const token = buildOAuthTokenPayload(credential, accessToken)
+
+ /**
+ * A resource-scoped credential whose granted scopes name no valid host holds
+ * a token no API will accept. Refusing here names the problem; letting it
+ * through surfaces as an opaque 401 from the provider that reads like an
+ * expired token rather than a credential that was never scoped to an
+ * environment. Reconnect cannot repair it — the origin it would recover is
+ * the missing part — so the message points at the one path that works.
+ */
+ if (getServiceConfigByProviderId(credential.providerId)?.resourceUrl && !token.resourceUrl) {
+ logger.warn(`[${requestId}] Resource-scoped credential has no granted resource scope`, {
+ providerId: credential.providerId,
+ })
+ return {
+ ok: false,
+ status: 400,
+ error:
+ 'This credential is not bound to an environment. Disconnect it and connect the account again.',
+ code: 'resource_scope_missing',
+ }
+ }
+
+ return { ok: true, token }
} catch (error) {
logger.error(`[${requestId}] Failed to refresh access token:`, error)
return { ok: false, status: 401, error: 'Failed to refresh access token' }
diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts
index 628ae367c49..e22237d0f59 100644
--- a/apps/sim/lib/oauth/types.ts
+++ b/apps/sim/lib/oauth/types.ts
@@ -188,6 +188,34 @@ export interface OAuthServiceConfig {
* which does not hint that the environment was the problem.
*/
providerIdPickerHint?: string
+ /**
+ * Set when the service's OAuth resource is the customer's own tenant host, so
+ * its scope cannot be declared statically alongside {@link scopes}. The
+ * connect flow collects the URL, validates it against
+ * {@link OAuthResourceUrlConfig.allowedHostSuffixes}, and appends
+ * `origin + scopeSuffix` to the requested scopes.
+ *
+ * Leave unset for every ordinary service — the presence of this field is what
+ * routes a connect attempt through the resource-aware path.
+ */
+ resourceUrl?: OAuthResourceUrlConfig
+}
+
+/**
+ * Describes a service whose OAuth resource is per-tenant. Data only, so the
+ * same declaration drives the connect modal's field, the client-side check, and
+ * the server-side check in the authorize route.
+ */
+export interface OAuthResourceUrlConfig {
+ /** Field label, also used to phrase validation errors. */
+ title: string
+ placeholder: string
+ /** One-line guidance under the field. */
+ hint?: string
+ /** Registrable domain suffixes the host must end with. */
+ allowedHostSuffixes: readonly string[]
+ /** Appended to the validated origin to form the resource scope. */
+ scopeSuffix: string
}
/**
@@ -202,6 +230,8 @@ export interface OAuthServiceMetadata {
description: string
baseProvider: string
authType: OAuthAuthType
+ /** Mirrors {@link OAuthServiceConfig.resourceUrl} for server-side callers. */
+ resourceUrl?: OAuthResourceUrlConfig
}
export interface Credential {
diff --git a/apps/sim/lib/oauth/utils.ts b/apps/sim/lib/oauth/utils.ts
index ecd0846ccdb..880a9a5a69c 100644
--- a/apps/sim/lib/oauth/utils.ts
+++ b/apps/sim/lib/oauth/utils.ts
@@ -255,7 +255,6 @@ export const SCOPE_DESCRIPTIONS: Record = {
'Sites.Read.All': 'Read Sharepoint sites',
'Sites.ReadWrite.All': 'Read and write Sharepoint sites',
'Sites.Manage.All': 'Manage Sharepoint sites',
- 'https://dynamics.microsoft.com/user_impersonation': 'Access Microsoft Dataverse on your behalf',
'User.Read.All': 'Read all user profiles',
'User.ReadWrite.All': 'Read and write all user profiles',
'GroupMember.ReadWrite.All': 'Read and write all group memberships',
@@ -485,6 +484,7 @@ export function getAllOAuthServices(): OAuthServiceMetadata[] {
description: service.description,
baseProvider: baseProviderId,
authType: service.authType ?? 'oauth',
+ resourceUrl: service.resourceUrl,
})
}
}
diff --git a/apps/sim/tools/microsoft_dataverse/utils.ts b/apps/sim/tools/microsoft_dataverse/utils.ts
index 5ff377bcee3..5c417322fa8 100644
--- a/apps/sim/tools/microsoft_dataverse/utils.ts
+++ b/apps/sim/tools/microsoft_dataverse/utils.ts
@@ -1,55 +1,22 @@
-/**
- * Registrable domains Microsoft serves Dataverse environments from — commercial and
- * regional clouds (`*.crm[N].dynamics.com`), China (21Vianet), US Government and DoD,
- * and the legacy German cloud. Matching on the registrable domain rather than each
- * regional `crmN` prefix keeps new Microsoft regions working without a code change.
- */
-const DATAVERSE_HOST_SUFFIXES = [
- '.dynamics.com',
- '.dynamics.cn',
- '.dynamics.de',
- '.microsoftdynamics.us',
- '.appsplatform.us',
-] as const
+import { DATAVERSE_RESOURCE_URL } from '@/lib/oauth/dataverse'
+import { resolveResourceOrigin } from '@/lib/oauth/resource-url'
/**
* Normalizes a Dataverse environment URL into a base URL suitable for building Web API request
- * paths: trims incidental whitespace (common when pasted from a browser address bar) and strips
- * a trailing slash so callers can safely append `/api/data/v9.2/...`.
+ * paths, so callers can safely append `/api/data/v9.2/...`.
*
* The value is user-supplied and every request built from it carries the caller's OAuth bearer
* token, so the host is pinned to Microsoft's Dataverse domains — an arbitrary origin here would
- * otherwise receive that token.
+ * otherwise receive that token. The same pinning decides the OAuth scope's audience at connect
+ * time, which is why both read one {@link DATAVERSE_RESOURCE_URL} rather than their own host list.
*
- * @throws {Error} when the value is empty, not a parseable absolute URL, not HTTPS, carries
- * credentials, or is not a Dataverse host.
+ * @throws {Error} when the value is empty, not a parseable URL, not HTTPS, carries credentials,
+ * or is not a Dataverse host.
*/
export function getDataverseBaseUrl(environmentUrl: string): string {
- const value = typeof environmentUrl === 'string' ? environmentUrl.trim() : ''
- if (!value) {
- throw new Error('Environment URL is required')
- }
-
- let parsed: URL
- try {
- parsed = new URL(value)
- } catch {
- throw new Error('Environment URL must be an absolute URL, e.g. https://myorg.crm.dynamics.com')
- }
-
- if (parsed.protocol !== 'https:') {
- throw new Error('Environment URL must use https')
- }
- if (parsed.username || parsed.password) {
- throw new Error('Environment URL must not contain credentials')
+ const resolved = resolveResourceOrigin(environmentUrl, DATAVERSE_RESOURCE_URL)
+ if (!resolved.ok) {
+ throw new Error(resolved.error)
}
-
- const hostname = parsed.hostname.toLowerCase()
- if (!DATAVERSE_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix))) {
- throw new Error(
- 'Environment URL must be a Dataverse environment, e.g. https://myorg.crm.dynamics.com'
- )
- }
-
- return parsed.origin
+ return resolved.origin
}
diff --git a/scripts/check-tool-registry-boundary.baseline.json b/scripts/check-tool-registry-boundary.baseline.json
index 5e8ebeb9d3b..06251c34105 100644
--- a/scripts/check-tool-registry-boundary.baseline.json
+++ b/scripts/check-tool-registry-boundary.baseline.json
@@ -138,7 +138,7 @@
}
},
"app/workspace/[workspaceId]/knowledge/page.tsx": {
- "modules": 2091,
+ "modules": 2135,
"gateways": {
"apps/sim/triggers/registry.ts": 446,
"apps/sim/blocks/registry.ts": 317,