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,