Skip to content
Merged

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions apps/sim/app/api/auth/oauth/credentials/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@ import { db } from '@sim/db'
import { account, credential, credentialMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import { and, eq, isNotNull } from 'drizzle-orm'
import { and, eq, inArray, isNotNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { oauthCredentialsQuerySchema } from '@/lib/api/contracts/credentials'
import { getValidationErrorMessage } from '@/lib/api/server'
Expand All@@ -14,6 +14,7 @@ import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
import {
getCanonicalScopesForProvider,
getServiceAccountProviderForProviderId,
providerIdsForService,
} from '@/lib/oauth/utils'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'

Expand DownExpand Up@@ -241,7 +242,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
and(
eq(credential.workspaceId, effectiveWorkspaceId),
eq(credential.type, 'oauth'),
eq(account.providerId, providerParam),
inArray(account.providerId, providerIdsForService(providerParam)),
requesterCanAdmin ? undefined : isNotNull(credentialMember.id)
)
)
Expand Down
9 changes: 8 additions & 1 deletion apps/sim/app/api/auth/oauth/disconnect/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { deleteCredential } from '@/lib/credentials/deletion'
import { providerIdsForService } from '@/lib/oauth/utils'
import { captureServerEvent } from '@/lib/posthog/server'

export const dynamic = 'force-dynamic'
Expand DownExpand Up@@ -61,7 +62,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
? and(eq(account.userId, session.user.id), eq(account.providerId, providerId))
: and(
eq(account.userId, session.user.id),
or(eq(account.providerId, provider), like(account.providerId, `${provider}-%`))
or(
// The prefix sweep already caught `{base}-{feature}` ids by
// accident; an alternate authorization server shares that shape,
// so name it explicitly rather than relying on the accident.
inArray(account.providerId, providerIdsForService(provider)),
like(account.providerId, `${provider}-%`)
)
)

const targetAccounts = await db.select({ id: account.id }).from(account).where(accountFilter)
Expand Down
61 changes: 61 additions & 0 deletions apps/sim/app/api/auth/oauth/token/route.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -547,3 +547,64 @@ describe('OAuth Token API Routes', () => {
})
})
})

describe('Salesforce instance URL resolution', () => {
const INSTANCE = 'https://acme--sbx.sandbox.my.salesforce.com'

beforeEach(() => {
vi.clearAllMocks()
authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValue(null)
mockAuthorizeCredentialUse.mockResolvedValue({
ok: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'owner-user-id',
})
authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockResolvedValue({
accessToken: 'fresh-token',
refreshed: false,
})
})

/**
* The org host is smuggled through `scope` because the token response has
* nowhere to put it; the tools read it back as their `instanceUrl` param.
*/
function credentialForProvider(providerId: string) {
return {
id: 'credential-id',
accessToken: 'test-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
providerId,
scope: `__sf_instance__:${INSTANCE} api refresh_token openid`,
}
}

it.each(['salesforce', 'salesforce-sandbox'])(
'returns the stored instance URL for a %s credential',
async (providerId) => {
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce(
credentialForProvider(providerId)
)

const response = await POST(createMockRequest('POST', { credentialId: 'credential-id' }))
const data = await response.json()

expect(response.status).toBe(200)
expect(data.instanceUrl).toBe(INSTANCE)
}
)

it('omits instanceUrl for a non-Salesforce provider carrying a lookalike scope', async () => {
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({
...credentialForProvider('google'),
})

const response = await POST(createMockRequest('POST', { credentialId: 'credential-id' }))
const data = await response.json()

expect(response.status).toBe(200)
expect(data.instanceUrl).toBeUndefined()
})
})
27 changes: 7 additions & 20 deletions apps/sim/app/api/auth/oauth/token/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,7 @@ import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
import { extractSalesforceInstanceUrl, isSalesforceOAuthProviderId } from '@/lib/oauth/salesforce'
import { captureServerEvent } from '@/lib/posthog/server'
import {
getCredential,
Expand All@@ -26,11 +27,6 @@ export const dynamic = 'force-dynamic'

const logger = createLogger('OAuthTokenAPI')

const SALESFORCE_INSTANCE_URL_REGEX = /__sf_instance__:([^\s]+)/
// Stop at a comma or whitespace: better-auth persists Zoho's scopes comma-joined
// (no spaces), so a greedy `\S+` would swallow the whole scope list into the host.
// The Desk base URL itself never contains a comma or space.

/**
* Get an access token for a specific credential
* Supports both session-based authentication (for client-side requests)
Expand DownExpand Up@@ -287,13 +283,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
)
}

let instanceUrl: string | undefined
if (credential.providerId === 'salesforce' && credential.scope) {
const instanceMatch = credential.scope.match(SALESFORCE_INSTANCE_URL_REGEX)
if (instanceMatch) {
instanceUrl = instanceMatch[1]
}
}
const instanceUrl = isSalesforceOAuthProviderId(credential.providerId)
? extractSalesforceInstanceUrl(credential.scope)
: undefined

// Zoho Desk persists its data-center-specific REST base URL in the scope
// string (derived from the token response api_domain) so callers never
Expand DownExpand Up@@ -410,14 +402,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
)
}

// For Salesforce, extract instanceUrl from the scope field
let instanceUrl: string | undefined
if (credential.providerId === 'salesforce' && credential.scope) {
const instanceMatch = credential.scope.match(SALESFORCE_INSTANCE_URL_REGEX)
if (instanceMatch) {
instanceUrl = instanceMatch[1]
}
}
const instanceUrl = isSalesforceOAuthProviderId(credential.providerId)
? extractSalesforceInstanceUrl(credential.scope)
: undefined

// Zoho Desk persists its data-center-specific REST base URL in the scope
// string (derived from the token response api_domain) so callers never
Expand Down
6 changes: 5 additions & 1 deletion apps/sim/app/api/auth/oauth/utils.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -466,7 +466,8 @@ function secretFingerprintOf(encryptedServiceAccountKey: string): string {

/**
* Resolves a client-credential service-account credential to a short-lived
* access token: decrypts the stored client id/secret + org id and mints via
* access token: decrypts the stored credential material (client id + secret,
* or the private key and run-as username for key-based grants) and mints via
* the provider's registered minter (skipping the connect-time identity
* lookup), read-through the per-instance cache. Wrapped in `coalesceLocally`
* so concurrent block executions on one instance share a single mint.
Expand DownExpand Up@@ -526,6 +527,9 @@ async function resolveClientCredentialAccountToken(
clientSecret: blob.clientSecret,
orgId: blob.orgId,
dataCenter: blob.dataCenter,
authMethod: blob.authMethod,
privateKey: blob.privateKey,
username: blob.username,
},
{ skipIdentity: true }
)
Expand Down
13 changes: 13 additions & 0 deletions apps/sim/app/api/auth/oauth2/authorize/route.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,19 @@ vi.mock('@/lib/credentials/access', () => ({

vi.mock('@/lib/oauth/utils', () => ({
getAllOAuthServices: 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: (
credentialProviderId: string,
service: {
providerId: string
serviceAccountProviderId?: string
additionalProviderIds?: readonly string[]
}
) =>
service.providerId === credentialProviderId ||
service.serviceAccountProviderId === credentialProviderId ||
(service.additionalProviderIds?.includes(credentialProviderId) ?? false),
}))

import { GET } from '@/app/api/auth/oauth2/authorize/route'
Expand Down
3 changes: 3 additions & 0 deletions apps/sim/app/api/credentials/[id]/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -90,6 +90,9 @@ export const PUT = withRouteHandler(
clientSecret: body.clientSecret,
orgId: body.orgId,
dataCenter: body.dataCenter,
authMethod: body.authMethod,
privateKey: body.privateKey,
username: body.username,
request,
})
if (!result.success) {
Expand Down
10 changes: 8 additions & 2 deletions apps/sim/app/api/credentials/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,7 +30,7 @@ import {
} from '@/lib/credentials/service-account-secret'
import { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service-accounts/descriptors'
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
import { getServiceConfigByProviderId } from '@/lib/oauth'
import { getServiceConfigByProviderId, providerIdsForService } from '@/lib/oauth'
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
import { captureServerEvent } from '@/lib/posthog/server'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
Expand DownExpand Up@@ -237,7 +237,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
whereClauses.push(eq(credential.type, type))
}
if (providerId) {
whereClauses.push(eq(credential.providerId, providerId))
whereClauses.push(inArray(credential.providerId, providerIdsForService(providerId)))
}

const isWorkspaceAdmin = workspaceAccess.canAdmin
Expand DownExpand Up@@ -331,6 +331,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
clientSecret,
orgId,
dataCenter,
authMethod,
privateKey,
username,
} = parsed.data.body

const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id)
Expand DownExpand Up@@ -392,6 +395,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
clientSecret,
orgId,
dataCenter,
authMethod,
privateKey,
username,
})
resolvedProviderId = secret.providerId
resolvedAccountId = null
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,7 +25,7 @@ import {
type OAuthProvider,
parseProvider,
} from '@/lib/oauth'
import { getScopeDescription } from '@/lib/oauth/utils'
import { getScopeDescription, getServiceConfigByProviderId } from '@/lib/oauth/utils'
import { useCreateCredentialDraft, useWorkspaceCredentials } from '@/hooks/queries/credentials'
import { useConnectOAuthService } from '@/hooks/queries/oauth/oauth-connections'

Expand DownExpand Up@@ -129,11 +129,34 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
const { open, onOpenChange, mode } = props
const isConnect = mode === 'connect'

const providerId = useMemo(
const declaredProviderId = useMemo(
() => props.providerId ?? (props.serviceId ? getProviderIdFromServiceId(props.serviceId) : ''),
[props.providerId, props.serviceId]
)

/**
* Authorization servers this service can be connected through, when it has
* more than one (Salesforce production vs sandbox). Offered on connect only:
* a reauthorize must return to the server that issued the credential.
*/
const { authServerOptions, authServerHint } = useMemo(() => {
const service = isConnect ? getServiceConfigByProviderId(declaredProviderId) : null
const labels = service?.providerIdLabels
if (!service?.additionalProviderIds?.length || !labels) {
return { authServerOptions: [], authServerHint: undefined }
}
return {
authServerOptions: [service.providerId, ...service.additionalProviderIds].map((value) => ({
value,
label: labels[value] ?? value,
})),
authServerHint: service.providerIdPickerHint,
}
}, [isConnect, declaredProviderId])

const [selectedProviderId, setSelectedProviderId] = useState<string | null>(null)
const providerId = selectedProviderId ?? declaredProviderId

const [displayName, setDisplayName] = useState('')
const [description, setDescription] = useState('')
const [validationError, setValidationError] = useState<string | null>(null)
Expand DownExpand Up@@ -202,6 +225,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
useEffect(() => {
if (!open) {
prefilled.current = false
setSelectedProviderId(null)
return
}
if (!isConnect || prefilled.current || credentialsLoading) return
Expand DownExpand Up@@ -329,6 +353,18 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
</p>
)}

{authServerOptions.length > 0 && (
<ChipModalField
type='dropdown'
title='Environment'
value={providerId}
onChange={setSelectedProviderId}
options={authServerOptions}
align='start'
hint={authServerHint}
/>
)}

{isConnect && (
<ChipModalField
type='input'
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,7 +22,7 @@ import {
} from '@/lib/credentials/oauth-chat-attempt'
import { getDesktopBridge } from '@/lib/desktop'
import type { OAuthProvider } from '@/lib/oauth/types'
import { parseProvider } from '@/lib/oauth/utils'
import { parseProvider, providerIdsForService } from '@/lib/oauth/utils'
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'

const OAUTH_POPUP_WINDOW_NAME = 'sim-oauth-connect'
Expand DownExpand Up@@ -188,7 +188,15 @@ export function useOAuthChipConnection({
} | null>(null)

const credentialTarget = useMemo(
() => ({ providerId, baseProviderId, credentialId: reconnectCredentialId }),
() => ({
providerId,
baseProviderId,
credentialId: reconnectCredentialId,
// A credential from an alternate authorization server (Salesforce
// sandbox) still connects this chip's service; without these the chip
// reads as disconnected and re-prompts a user who is already connected.
additionalProviderIds: providerIdsForService(providerId),
}),
[baseProviderId, providerId, reconnectCredentialId]
)
const credentialScope = `${workspaceId}:${providerId}:${reconnectCredentialId ?? ''}`
Expand DownExpand Up@@ -471,6 +479,7 @@ export function useOAuthChipConnection({
workspaceId,
providerId,
baseProviderId,
additionalProviderIds: credentialTarget.additionalProviderIds,
displayName,
controlId,
credentialId: reconnectCredentialId,
Expand Down
Loading
Loading