From 21663cb71564109ae739867544614c22a1e8ba72 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 14 Aug 2026 14:49:11 -0700 Subject: [PATCH 1/5] fix(dataverse): request the per-environment OAuth scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../api/auth/oauth2/authorize/route.test.ts | 3 + .../app/api/auth/oauth2/authorize/route.ts | 16 ++++++ apps/sim/app/desktop/connect/page.tsx | 20 +++++++ .../connect-oauth-modal.tsx | 43 ++++++++++++++- .../hooks/queries/oauth/oauth-connections.ts | 51 ++++++++++++++++- apps/sim/lib/copilot/tools/handlers/oauth.ts | 13 +++++ apps/sim/lib/oauth/dataverse.ts | 29 ++++++++++ apps/sim/lib/oauth/oauth.ts | 17 +++--- apps/sim/lib/oauth/resource-url.test.ts | 52 ++++++++++++++++++ apps/sim/lib/oauth/resource-url.ts | 55 +++++++++++++++++++ apps/sim/lib/oauth/types.ts | 30 ++++++++++ apps/sim/lib/oauth/utils.ts | 2 +- apps/sim/tools/microsoft_dataverse/utils.ts | 55 ++++--------------- 13 files changed, 330 insertions(+), 56 deletions(-) create mode 100644 apps/sim/lib/oauth/dataverse.ts create mode 100644 apps/sim/lib/oauth/resource-url.test.ts create mode 100644 apps/sim/lib/oauth/resource-url.ts 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 && ( { + 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/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..aebf1f65a02 --- /dev/null +++ b/apps/sim/lib/oauth/resource-url.test.ts @@ -0,0 +1,52 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { 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 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) + }) +}) diff --git a/apps/sim/lib/oauth/resource-url.ts b/apps/sim/lib/oauth/resource-url.ts new file mode 100644 index 00000000000..6988095f877 --- /dev/null +++ b/apps/sim/lib/oauth/resource-url.ts @@ -0,0 +1,55 @@ +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` } + } + + /** + * 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 } +} 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 } From 20a590e2c0bce799f64035edf857b64c4473f1a0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 14 Aug 2026 14:57:42 -0700 Subject: [PATCH 2/5] fix(dataverse): reject port-qualified origins, refuse reconnect without a host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolve: an explicit port survived into the origin, which becomes both the OAuth audience and the API base. Providers publish their resource on the default port, so a port-qualified origin names a resource nobody serves. `URL` drops the port only when it is the scheme default, so anything left is explicit and now rejected. - reconnect: the credential-detail Reconnect action calls the connect mutation directly with no environment URL, so a resource-scoped service failed with a validator error about a field that surface never shows. It now refuses up front with the route that works. The check runs before the draft is written, so a refusal leaves nothing behind. `WorkspaceCredential` carries no granted scopes, so the origin cannot be read back off the credential here — that is the follow-up already noted on the PR. --- .../[credentialId]/connected-credential-detail.tsx | 14 ++++++++++++++ apps/sim/lib/oauth/resource-url.test.ts | 9 +++++++++ apps/sim/lib/oauth/resource-url.ts | 9 +++++++++ 3 files changed, 32 insertions(+) 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 2b1ae99f392..385270c92eb 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 @@ -19,6 +19,7 @@ import { useRouter } from 'next/navigation' import { SaveDiscardChips } from '@/components/settings/save-discard-actions' import { writeOAuthReturnContext } from '@/lib/credentials/client-state' import { resolveCredentialDisplay } from '@/lib/integrations' +import { getServiceConfigByProviderId } from '@/lib/oauth/utils' import { AddPeopleModal, CredentialDetailHeading, @@ -113,6 +114,19 @@ 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 belongs to, + * and this surface has no way to supply it — `WorkspaceCredential` carries + * no granted scopes to read the origin back from. Checked before the draft + * is written so a refusal leaves nothing behind. + */ + const resourceConfig = getServiceConfigByProviderId(credential.providerId)?.resourceUrl + if (resourceConfig) { + throw new Error( + `Reconnecting ${credential.displayName} needs its ${resourceConfig.title}. Disconnect it here, then connect the account again to enter one.` + ) + } + await createDraft.mutateAsync({ workspaceId, providerId: credential.providerId, diff --git a/apps/sim/lib/oauth/resource-url.test.ts b/apps/sim/lib/oauth/resource-url.test.ts index aebf1f65a02..60fe1b683ee 100644 --- a/apps/sim/lib/oauth/resource-url.test.ts +++ b/apps/sim/lib/oauth/resource-url.test.ts @@ -41,6 +41,15 @@ describe('resolveResourceOrigin', () => { 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) }) diff --git a/apps/sim/lib/oauth/resource-url.ts b/apps/sim/lib/oauth/resource-url.ts index 6988095f877..3de4eba4772 100644 --- a/apps/sim/lib/oauth/resource-url.ts +++ b/apps/sim/lib/oauth/resource-url.ts @@ -40,6 +40,15 @@ export function resolveResourceOrigin( 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, From 9f86672be26762793a5ad4990340a59418b5366a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 14 Aug 2026 15:18:19 -0700 Subject: [PATCH 3/5] chore(audits): re-record the knowledge page module baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:tool-registry-boundary` compares each route's module graph against a recorded count and fails past max(25, 2%) growth. The knowledge page had drifted to 2133 against a baseline of 2091 — exactly the +42 ceiling — so the next change to reach that graph pays for the accumulated drift. This branch adds 2 modules to it: the connect modal is used by the knowledge connectors section, and it now imports the resource-URL validator and the shared Dataverse config. That tips 2133 to 2135 and trips the gate. Re-recorded that one route rather than all 34, so the diff shows the number that actually moved. 42 of the 44 predate this branch. --- scripts/check-tool-registry-boundary.baseline.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From 77eab6f7b833eb5c8a96eb27098ca3672b292ef3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 14 Aug 2026 15:48:03 -0700 Subject: [PATCH 4/5] feat(oauth): bind a resource-scoped credential to its environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OAuth scope for a resource-scoped service names the tenant origin by construction, so the granted scope already records which environment a credential belongs to. Nothing read it back, which left two gaps. - Token resolution now projects that origin as `resourceUrl`, the way Salesforce's `instanceUrl` and Zoho's `apiDomain` already are. Unlike those two it is not smuggled into the scope column; it is recovered from the scope itself. - A resource-scoped credential whose granted scopes name no valid host holds a token no API will accept. Resolution now refuses it with that explanation instead of handing back a token that fails later as an opaque 401 — which reads like an expired credential rather than one never bound to an environment. Reconnect cannot repair it, since the origin it would recover is the missing part, so the message points at disconnect-and-connect. - Reconnect recovers the origin instead of refusing outright. It reads the scopes granted to *that account*, now projected per account on the connections response, rather than the connection's union across accounts — two accounts on one provider are two different environments, and the union cannot tell them apart. Picking the wrong one would rebind the credential to the wrong environment, which is the failure this is meant to prevent. The per-account scopes were already computed in the connections route and merged away, so this projects a value that was in hand rather than widening a query. Deriving it on the workspace-credential list would instead have meant a new join to `account` on a hot path shared by the workflow credential selector, the integrations page, and the v2 API. --- .../app/api/auth/oauth/connections/route.ts | 1 + .../connected-credential-detail.tsx | 33 ++++++++++++---- .../lib/api/contracts/oauth-connections.ts | 13 +++++++ apps/sim/lib/oauth/resource-url.test.ts | 27 ++++++++++++- apps/sim/lib/oauth/resource-url.ts | 22 +++++++++++ apps/sim/lib/oauth/token-resolution.ts | 38 ++++++++++++++++++- 6 files changed, 125 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/api/auth/oauth/connections/route.ts b/apps/sim/app/api/auth/oauth/connections/route.ts index 9af427f9c17..546b1cf4873 100644 --- a/apps/sim/app/api/auth/oauth/connections/route.ts +++ b/apps/sim/app/api/auth/oauth/connections/route.ts @@ -97,6 +97,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const accountSummary = { id: acc.id, name: displayName, + scopes, } if (existingConnection) { 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 385270c92eb..9b0bc4c2142 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 @@ -19,6 +19,7 @@ import { useRouter } from 'next/navigation' import { SaveDiscardChips } from '@/components/settings/save-discard-actions' import { writeOAuthReturnContext } from '@/lib/credentials/client-state' import { resolveCredentialDisplay } from '@/lib/integrations' +import { findGrantedResourceOrigin } from '@/lib/oauth/resource-url' import { getServiceConfigByProviderId } from '@/lib/oauth/utils' import { AddPeopleModal, @@ -92,6 +93,16 @@ export function ConnectedCredentialDetail({ const form = useCredentialDetailForm({ credential, isAdmin, backHref: integrationsHref }) + /** Scopes granted to this credential's own account, not the provider's union. */ + const accountScopes = useMemo(() => { + if (!credential?.accountId) return undefined + for (const service of oauthConnections) { + const account = service.accounts?.find((entry) => entry.id === credential.accountId) + if (account) return account.scopes + } + return undefined + }, [oauthConnections, credential?.accountId]) + const oauthServiceNameByProviderId = useMemo( () => new Map(oauthConnections.map((service) => [service.providerId, service.name])), [oauthConnections] @@ -115,16 +126,23 @@ export function ConnectedCredentialDetail({ if (!credential || credential.type !== 'oauth' || !credential.providerId || !workspaceId) return try { /** - * A reconnect must return to the environment the credential belongs to, - * and this surface has no way to supply it — `WorkspaceCredential` carries - * no granted scopes to read the origin back from. Checked before the draft - * is written so a refusal leaves nothing behind. + * A reconnect must return to the environment the credential already + * belongs to, so the origin is read back off the scopes granted to *this* + * account rather than asked for again. The account's own scopes are used, + * not the connection's union — two accounts on one provider are two + * different environments, and the union cannot tell them apart. + * + * Resolved before the draft is written so a failure leaves nothing behind. */ const resourceConfig = getServiceConfigByProviderId(credential.providerId)?.resourceUrl + let resourceUrl: string | undefined if (resourceConfig) { - throw new Error( - `Reconnecting ${credential.displayName} needs its ${resourceConfig.title}. Disconnect it here, then connect the account again to enter one.` - ) + resourceUrl = findGrantedResourceOrigin(accountScopes, 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({ @@ -151,6 +169,7 @@ export function ConnectedCredentialDetail({ await connectOAuthService.mutateAsync({ providerId: credential.providerId, callbackURL: window.location.href, + resourceUrl, }) } catch (error: unknown) { toast.error("Couldn't start reconnect", { diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index c9c4951efd2..38db1904318 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -10,6 +10,13 @@ import { defineRouteContract } from '@/lib/api/contracts/types' export const oauthAccountSummarySchema = z.object({ id: z.string(), name: z.string(), + /** + * Scopes granted to this account specifically. The connection's own `scopes` + * is the union across its accounts, which cannot answer a per-credential + * question — for a service whose scope names a tenant host, two accounts on + * one provider are two different environments. + */ + scopes: z.array(z.string()).optional(), }) export type OAuthAccountSummary = z.output @@ -97,6 +104,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/oauth/resource-url.test.ts b/apps/sim/lib/oauth/resource-url.test.ts index 60fe1b683ee..e39e4c4e02f 100644 --- a/apps/sim/lib/oauth/resource-url.test.ts +++ b/apps/sim/lib/oauth/resource-url.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { resolveResourceOrigin } from '@/lib/oauth/resource-url' +import { findGrantedResourceOrigin, resolveResourceOrigin } from '@/lib/oauth/resource-url' import type { OAuthResourceUrlConfig } from '@/lib/oauth/types' const config: OAuthResourceUrlConfig = { @@ -59,3 +59,28 @@ describe('resolveResourceOrigin', () => { 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 index 3de4eba4772..cfcd394c7d3 100644 --- a/apps/sim/lib/oauth/resource-url.ts +++ b/apps/sim/lib/oauth/resource-url.ts @@ -62,3 +62,25 @@ export function resolveResourceOrigin( 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' } From 9e86421d7cb4f0ece9faa153655e66b21c5f5cef Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 14 Aug 2026 16:01:14 -0700 Subject: [PATCH 5/5] fix(oauth): read reconnect scopes from the credential, not the viewer's connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reconnect handler recovered a resource-scoped credential's environment from useOAuthConnections(), which lists the *viewer's* own accounts and falls back to a bare catalog on a failed fetch. An admin reconnecting a teammate's shared credential found no matching account, and a still-loading or failed query was indistinguishable from no scopes granted — both surfaced as "not bound to an Environment URL" on a perfectly valid credential. Read the scopes from the credential itself instead. The credential detail route already left-joins the backing account and authorizes by credential access rather than ownership, so it answers for a shared credential and carries its own load and error states: Reconnect stays disabled while they load, a failed read says so, and only an authoritative empty result gives the unbound refusal. The per-account scopes projection added for the previous approach is reverted with it. --- .../app/api/auth/oauth/connections/route.ts | 1 - .../connected-credential-detail.tsx | 45 ++++++++++++------- .../lib/api/contracts/oauth-connections.ts | 7 --- 3 files changed, 30 insertions(+), 23 deletions(-) diff --git a/apps/sim/app/api/auth/oauth/connections/route.ts b/apps/sim/app/api/auth/oauth/connections/route.ts index 546b1cf4873..9af427f9c17 100644 --- a/apps/sim/app/api/auth/oauth/connections/route.ts +++ b/apps/sim/app/api/auth/oauth/connections/route.ts @@ -97,7 +97,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const accountSummary = { id: acc.id, name: displayName, - scopes, } if (existingConnection) { 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 9b0bc4c2142..cb24c4d923a 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 @@ -51,6 +51,7 @@ import { useDisconnectOAuthService, useOAuthConnections, } from '@/hooks/queries/oauth/oauth-connections' +import { useOAuthCredentialDetail } from '@/hooks/queries/oauth/oauth-credentials' import { useOAuthReturnRouter } from '@/hooks/use-oauth-return' const logger = createLogger('ConnectedCredentialDetail') @@ -93,15 +94,26 @@ export function ConnectedCredentialDetail({ const form = useCredentialDetailForm({ credential, isAdmin, backHref: integrationsHref }) - /** Scopes granted to this credential's own account, not the provider's union. */ - const accountScopes = useMemo(() => { - if (!credential?.accountId) return undefined - for (const service of oauthConnections) { - const account = service.accounts?.find((entry) => entry.id === credential.accountId) - if (account) return account.scopes - } - return undefined - }, [oauthConnections, credential?.accountId]) + /** + * Scopes granted to this specific credential, read from the workspace-scoped + * credential detail rather than `useOAuthConnections` — the latter returns the + * *viewer's* connections, so an admin reconnecting a teammate's shared + * credential would find no account, and it resolves to a bare catalog on a + * failed fetch, which is indistinguishable from "no scopes granted". + */ + const isResourceScoped = Boolean( + credential?.providerId && getServiceConfigByProviderId(credential.providerId)?.resourceUrl + ) + const { + data: credentialDetail = [], + isPending: scopesLoading, + isError: scopesUnavailable, + } = useOAuthCredentialDetail( + isResourceScoped ? credentialId : undefined, + undefined, + isResourceScoped + ) + const grantedScopes = credentialDetail[0]?.scopes const oauthServiceNameByProviderId = useMemo( () => new Map(oauthConnections.map((service) => [service.providerId, service.name])), @@ -127,17 +139,20 @@ export function ConnectedCredentialDetail({ try { /** * A reconnect must return to the environment the credential already - * belongs to, so the origin is read back off the scopes granted to *this* - * account rather than asked for again. The account's own scopes are used, - * not the connection's union — two accounts on one provider are two - * different environments, and the union cannot tell them apart. + * 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) { - resourceUrl = findGrantedResourceOrigin(accountScopes, 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.` @@ -229,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/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index 38db1904318..52bdd1f637d 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -10,13 +10,6 @@ import { defineRouteContract } from '@/lib/api/contracts/types' export const oauthAccountSummarySchema = z.object({ id: z.string(), name: z.string(), - /** - * Scopes granted to this account specifically. The connection's own `scopes` - * is the union across its accounts, which cannot answer a per-credential - * question — for a service whose scope names a tenant host, two accounts on - * one provider are two different environments. - */ - scopes: z.array(z.string()).optional(), }) export type OAuthAccountSummary = z.output