Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/sim/app/api/auth/oauth2/authorize/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: (
Expand Down
16 changes: 16 additions & 0 deletions apps/sim/app/api/auth/oauth2/authorize/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions apps/sim/app/desktop/connect/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 (
<DesktopHandoffShell
title='Connect this account from your browser'
description='This integration needs its environment URL, which the desktop app cannot pass along. Open Sim in your browser and connect it from Settings → Integrations.'
/>
)
}

/**
* 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
Expand Down Expand Up @@ -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 <ResourceUrlUnsupported />
}

if (workspaceId) {
const authorize = new URL('/api/auth/oauth2/authorize', getBaseUrl())
authorize.searchParams.set('providerId', providerId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -157,6 +158,23 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
const [selectedProviderId, setSelectedProviderId] = useState<string | null>(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<string | null>(null)
Expand Down Expand Up @@ -226,6 +244,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
if (!open) {
prefilled.current = false
setSelectedProviderId(null)
setResourceUrl('')
return
}
if (!isConnect || prefilled.current || credentialsLoading) return
Expand Down Expand Up @@ -319,6 +338,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
await connectOAuthService.mutateAsync({
providerId,
callbackURL: callbackURL.toString(),
resourceUrl,
Comment thread
cursor[bot] marked this conversation as resolved.
})
handleClose()
} catch (err: unknown) {
Expand All @@ -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 ??
Expand All @@ -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 && (
<ChipModalField
type='input'
title={resourceConfig.title}
value={resourceUrl}
onChange={setResourceUrl}
placeholder={resourceConfig.placeholder}
autoComplete='off'
required
hint={resourceConfig.hint}
error={resourceError}
/>
)}

{isConnect && (
<ChipModalField
type='input'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ 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,
CredentialDetailHeading,
Expand Down Expand Up @@ -49,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')
Expand Down Expand Up @@ -91,6 +94,27 @@ export function ConnectedCredentialDetail({

const form = useCredentialDetailForm({ credential, isAdmin, backHref: integrationsHref })

/**
* 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])),
[oauthConnections]
Expand All @@ -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.`
)
}
Comment thread
waleedlatif1 marked this conversation as resolved.
}

await createDraft.mutateAsync({
workspaceId,
providerId: credential.providerId,
Expand All @@ -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", {
Expand Down Expand Up @@ -196,7 +244,7 @@ export function ConnectedCredentialDetail({
? () => setReconnectOpen(true)
: handleReconnectOAuth
}
disabled={connectOAuthService.isPending}
disabled={connectOAuthService.isPending || (isResourceScoped && scopesLoading)}
leftIcon={display?.icon ?? undefined}
>
Reconnect
Expand Down
51 changes: 50 additions & 1 deletion apps/sim/hooks/queries/oauth/oauth-connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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}`]
}

/**
Expand All @@ -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}`
Expand All @@ -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.')
Expand All @@ -187,6 +233,9 @@ export function useConnectOAuthService() {
await client.oauth2.link({
providerId,
callbackURL,
...(resourceConfig && service
? { scopes: resolveConnectScopes(service, resourceConfig, resourceUrl) }
: {}),
Comment thread
waleedlatif1 marked this conversation as resolved.
Comment thread
waleedlatif1 marked this conversation as resolved.
})

return { success: true }
Expand Down
6 changes: 6 additions & 0 deletions apps/sim/lib/api/contracts/oauth-connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
13 changes: 13 additions & 0 deletions apps/sim/lib/copilot/tools/handlers/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading