diff --git a/apps/sim/app/api/auth/oauth/utils.test.ts b/apps/sim/app/api/auth/oauth/utils.test.ts
index 2c61b903f..12d8b9e3a 100644
--- a/apps/sim/app/api/auth/oauth/utils.test.ts
+++ b/apps/sim/app/api/auth/oauth/utils.test.ts
@@ -153,7 +153,7 @@ describe('OAuth Utils', () => {
const result = await refreshTokenIfNeeded('request-id', mockCredential, 'credential-id')
- expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token')
+ expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token', undefined)
expect(mockDb.update).toHaveBeenCalled()
expect(mockDb.set).toHaveBeenCalled()
expect(result).toEqual({ accessToken: 'new-token', refreshed: true })
@@ -228,7 +228,7 @@ describe('OAuth Utils', () => {
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
- expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token')
+ expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token', undefined)
expect(mockDb.update).toHaveBeenCalled()
expect(mockDb.set).toHaveBeenCalled()
expect(token).toBe('new-token')
diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts
index b23cf06da..7a5054589 100644
--- a/apps/sim/app/api/auth/oauth/utils.ts
+++ b/apps/sim/app/api/auth/oauth/utils.ts
@@ -131,8 +131,11 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
)
try {
- // Use the existing refreshOAuthToken function
- const refreshResult = await refreshOAuthToken(providerId, credential.refreshToken!)
+ const refreshResult = await refreshOAuthToken(
+ providerId,
+ credential.refreshToken!,
+ credential.idToken || undefined
+ )
if (!refreshResult) {
logger.error(`Failed to refresh token for user ${userId}, provider ${providerId}`, {
@@ -217,7 +220,8 @@ export async function refreshAccessTokenIfNeeded(
try {
const refreshedToken = await refreshOAuthToken(
credential.providerId,
- credential.refreshToken!
+ credential.refreshToken!,
+ credential.idToken || undefined
)
if (!refreshedToken) {
@@ -289,7 +293,11 @@ export async function refreshTokenIfNeeded(
}
try {
- const refreshResult = await refreshOAuthToken(credential.providerId, credential.refreshToken!)
+ const refreshResult = await refreshOAuthToken(
+ credential.providerId,
+ credential.refreshToken!,
+ credential.idToken || undefined
+ )
if (!refreshResult) {
logger.error(`[${requestId}] Failed to refresh token for credential`)
diff --git a/apps/sim/app/api/auth/oauth2/callback/salesforce/route.ts b/apps/sim/app/api/auth/oauth2/callback/salesforce/route.ts
new file mode 100644
index 000000000..b5df148d5
--- /dev/null
+++ b/apps/sim/app/api/auth/oauth2/callback/salesforce/route.ts
@@ -0,0 +1,221 @@
+import { db } from '@sim/db'
+import { account } from '@sim/db/schema'
+import { and, eq } from 'drizzle-orm'
+import { type NextRequest, NextResponse } from 'next/server'
+import { getSession } from '@/lib/auth'
+import { env } from '@/lib/core/config/env'
+import { getBaseUrl } from '@/lib/core/utils/urls'
+import { createLogger } from '@/lib/logs/console/logger'
+import { safeAccountInsert } from '@/app/api/auth/oauth/utils'
+
+const logger = createLogger('SalesforceCallback')
+
+export const dynamic = 'force-dynamic'
+
+export async function GET(request: NextRequest) {
+ const baseUrl = getBaseUrl()
+
+ try {
+ const session = await getSession()
+ if (!session?.user?.id) {
+ logger.warn('Unauthorized attempt to complete Salesforce OAuth')
+ return NextResponse.redirect(`${baseUrl}/workspace?error=unauthorized`)
+ }
+
+ const { searchParams } = request.nextUrl
+ const code = searchParams.get('code')
+ const state = searchParams.get('state')
+ const error = searchParams.get('error')
+ const errorDescription = searchParams.get('error_description')
+
+ if (error) {
+ logger.error('Salesforce OAuth error:', { error, errorDescription })
+ return NextResponse.redirect(
+ `${baseUrl}/workspace?error=salesforce_oauth_error&message=${encodeURIComponent(errorDescription || error)}`
+ )
+ }
+
+ const storedState = request.cookies.get('salesforce_oauth_state')?.value
+ const storedVerifier = request.cookies.get('salesforce_pkce_verifier')?.value
+ const storedBaseUrl = request.cookies.get('salesforce_base_url')?.value
+ const returnUrl = request.cookies.get('salesforce_return_url')?.value
+
+ const clientId = env.SALESFORCE_CLIENT_ID
+ const clientSecret = env.SALESFORCE_CLIENT_SECRET
+
+ if (!clientId || !clientSecret) {
+ logger.error('Salesforce credentials not configured')
+ return NextResponse.redirect(`${baseUrl}/workspace?error=salesforce_config_error`)
+ }
+
+ if (!state || state !== storedState) {
+ logger.error('State mismatch in Salesforce OAuth callback', {
+ receivedState: state,
+ storedState: storedState ? 'present' : 'missing',
+ })
+ return NextResponse.redirect(`${baseUrl}/workspace?error=salesforce_state_mismatch`)
+ }
+
+ if (!code) {
+ logger.error('No authorization code received from Salesforce')
+ return NextResponse.redirect(`${baseUrl}/workspace?error=salesforce_no_code`)
+ }
+
+ if (!storedVerifier || !storedBaseUrl) {
+ logger.error('Missing PKCE verifier or base URL')
+ return NextResponse.redirect(`${baseUrl}/workspace?error=salesforce_missing_data`)
+ }
+
+ const tokenUrl = `${storedBaseUrl}/services/oauth2/token`
+ const redirectUri = `${baseUrl}/api/auth/oauth2/callback/salesforce`
+
+ const tokenParams = new URLSearchParams({
+ grant_type: 'authorization_code',
+ code: code,
+ client_id: clientId,
+ client_secret: clientSecret,
+ redirect_uri: redirectUri,
+ code_verifier: storedVerifier,
+ })
+
+ const tokenResponse = await fetch(tokenUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: tokenParams.toString(),
+ })
+
+ if (!tokenResponse.ok) {
+ const errorText = await tokenResponse.text()
+ logger.error('Failed to exchange code for token:', {
+ status: tokenResponse.status,
+ body: errorText,
+ tokenUrl,
+ })
+ return NextResponse.redirect(`${baseUrl}/workspace?error=salesforce_token_error`)
+ }
+
+ const tokenData = await tokenResponse.json()
+ const accessToken = tokenData.access_token
+ const refreshToken = tokenData.refresh_token
+ const instanceUrl = tokenData.instance_url
+ const scope = tokenData.scope
+ // Salesforce returns expires_in in seconds, default to 7200 (2 hours) if not provided
+ const expiresIn = tokenData.expires_in ? Number(tokenData.expires_in) : 7200
+
+ logger.info('Salesforce token exchange successful:', {
+ hasAccessToken: !!accessToken,
+ hasRefreshToken: !!refreshToken,
+ instanceUrl,
+ scope,
+ expiresIn,
+ })
+
+ if (!accessToken) {
+ logger.error('No access token in Salesforce response')
+ return NextResponse.redirect(`${baseUrl}/workspace?error=salesforce_no_token`)
+ }
+
+ if (!instanceUrl) {
+ logger.error('No instance URL in Salesforce response')
+ return NextResponse.redirect(`${baseUrl}/workspace?error=salesforce_no_instance`)
+ }
+
+ let userId = 'unknown'
+ let userEmail = ''
+ try {
+ const userInfoUrl = `${instanceUrl}/services/oauth2/userinfo`
+ const userInfoResponse = await fetch(userInfoUrl, {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ })
+ if (userInfoResponse.ok) {
+ const userInfo = await userInfoResponse.json()
+ userId = userInfo.user_id || userInfo.sub || 'unknown'
+ userEmail = userInfo.email || ''
+ }
+ } catch (userInfoError) {
+ logger.warn('Failed to fetch Salesforce user info:', userInfoError)
+ }
+
+ const existing = await db.query.account.findFirst({
+ where: and(eq(account.userId, session.user.id), eq(account.providerId, 'salesforce')),
+ })
+
+ const now = new Date()
+ const expiresAt = new Date(now.getTime() + expiresIn * 1000)
+
+ /**
+ * Store both instanceUrl (API endpoint) and authBaseUrl (OAuth endpoint) in idToken field.
+ * - instanceUrl: Used for API calls (e.g., https://na1.salesforce.com)
+ * - authBaseUrl: Used for token refresh (e.g., https://login.salesforce.com or custom domain)
+ * This is a non-standard use of the idToken field, but necessary for Salesforce's
+ * multi-endpoint OAuth architecture.
+ */
+ const salesforceMetadata = JSON.stringify({
+ instanceUrl: instanceUrl,
+ authBaseUrl: storedBaseUrl,
+ })
+
+ const accountData = {
+ accessToken: accessToken,
+ refreshToken: refreshToken || null,
+ accountId: userId,
+ scope: scope || '',
+ updatedAt: now,
+ accessTokenExpiresAt: expiresAt,
+ idToken: salesforceMetadata,
+ }
+
+ if (existing) {
+ await db.update(account).set(accountData).where(eq(account.id, existing.id))
+ logger.info('Updated existing Salesforce account', { accountId: existing.id })
+ } else {
+ await safeAccountInsert(
+ {
+ id: `salesforce_${session.user.id}_${Date.now()}`,
+ userId: session.user.id,
+ providerId: 'salesforce',
+ accountId: accountData.accountId,
+ accessToken: accountData.accessToken,
+ refreshToken: accountData.refreshToken || undefined,
+ scope: accountData.scope,
+ idToken: accountData.idToken,
+ accessTokenExpiresAt: accountData.accessTokenExpiresAt,
+ createdAt: now,
+ updatedAt: now,
+ },
+ { provider: 'Salesforce', identifier: userEmail || userId }
+ )
+ }
+
+ let redirectUrl = `${baseUrl}/workspace`
+ if (returnUrl) {
+ try {
+ const returnUrlObj = new URL(returnUrl, baseUrl)
+ if (returnUrlObj.origin === new URL(baseUrl).origin) {
+ redirectUrl = returnUrl
+ } else {
+ logger.warn('Invalid returnUrl origin, ignoring', { returnUrl, baseUrl })
+ }
+ } catch {
+ logger.warn('Invalid returnUrl format, ignoring', { returnUrl })
+ }
+ }
+ const finalUrl = new URL(redirectUrl, baseUrl)
+ finalUrl.searchParams.set('salesforce_connected', 'true')
+
+ const response = NextResponse.redirect(finalUrl.toString())
+ response.cookies.delete('salesforce_oauth_state')
+ response.cookies.delete('salesforce_pkce_verifier')
+ response.cookies.delete('salesforce_base_url')
+ response.cookies.delete('salesforce_return_url')
+
+ return response
+ } catch (error) {
+ logger.error('Error in Salesforce OAuth callback:', error)
+ return NextResponse.redirect(`${baseUrl}/workspace?error=salesforce_callback_error`)
+ }
+}
diff --git a/apps/sim/app/api/auth/salesforce/authorize/route.ts b/apps/sim/app/api/auth/salesforce/authorize/route.ts
new file mode 100644
index 000000000..492ea67d4
--- /dev/null
+++ b/apps/sim/app/api/auth/salesforce/authorize/route.ts
@@ -0,0 +1,362 @@
+import { type NextRequest, NextResponse } from 'next/server'
+import { getSession } from '@/lib/auth'
+import { env } from '@/lib/core/config/env'
+import { getBaseUrl } from '@/lib/core/utils/urls'
+import { createLogger } from '@/lib/logs/console/logger'
+
+const logger = createLogger('SalesforceAuthorize')
+
+export const dynamic = 'force-dynamic'
+
+const SALESFORCE_SCOPES = ['api', 'refresh_token', 'openid', 'offline_access'].join(' ')
+
+/**
+ * Generates a PKCE code verifier and challenge
+ */
+async function generatePKCE(): Promise<{ verifier: string; challenge: string }> {
+ const array = new Uint8Array(32)
+ crypto.getRandomValues(array)
+ const verifier = Buffer.from(array).toString('base64url')
+
+ const encoder = new TextEncoder()
+ const data = encoder.encode(verifier)
+ const digest = await crypto.subtle.digest('SHA-256', data)
+ const challenge = Buffer.from(digest).toString('base64url')
+
+ return { verifier, challenge }
+}
+
+export async function GET(request: NextRequest) {
+ try {
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const clientId = env.SALESFORCE_CLIENT_ID
+
+ if (!clientId) {
+ logger.error('SALESFORCE_CLIENT_ID not configured')
+ return NextResponse.json({ error: 'Salesforce client ID not configured' }, { status: 500 })
+ }
+
+ const orgType = request.nextUrl.searchParams.get('orgType')
+ const customDomain = request.nextUrl.searchParams.get('customDomain')
+ const returnUrl = request.nextUrl.searchParams.get('returnUrl')
+
+ if (!orgType) {
+ const returnUrlParam = returnUrl ? encodeURIComponent(returnUrl) : ''
+ return new NextResponse(
+ `
+
+
+ Connect Salesforce
+
+
+
+
+
+
+
Connect Your Salesforce Account
+
Select your Salesforce environment type
+
+
+
+
Your data stays secure with Salesforce's OAuth 2.0
+
+
+
+
+`,
+ {
+ headers: {
+ 'Content-Type': 'text/html; charset=utf-8',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ },
+ }
+ )
+ }
+
+ let salesforceBaseUrl: string
+ if (orgType === 'production') {
+ salesforceBaseUrl = 'https://login.salesforce.com'
+ } else if (orgType === 'sandbox') {
+ salesforceBaseUrl = 'https://test.salesforce.com'
+ } else if (orgType === 'custom' && customDomain) {
+ const cleanDomain = customDomain
+ .toLowerCase()
+ .trim()
+ .replace('https://', '')
+ .replace('http://', '')
+ if (!/^[a-zA-Z0-9-]+\.my\.salesforce\.com$/.test(cleanDomain)) {
+ logger.error('Invalid Salesforce custom domain format', { customDomain: cleanDomain })
+ return NextResponse.json({ error: 'Invalid custom domain format' }, { status: 400 })
+ }
+ salesforceBaseUrl = `https://${cleanDomain}`
+ } else {
+ logger.error('Invalid org type or missing custom domain')
+ return NextResponse.json({ error: 'Invalid org type' }, { status: 400 })
+ }
+
+ const baseUrl = getBaseUrl()
+ const redirectUri = `${baseUrl}/api/auth/oauth2/callback/salesforce`
+
+ const state = crypto.randomUUID()
+ const { verifier, challenge } = await generatePKCE()
+
+ const authParams = new URLSearchParams({
+ client_id: clientId,
+ redirect_uri: redirectUri,
+ response_type: 'code',
+ scope: SALESFORCE_SCOPES,
+ state: state,
+ code_challenge: challenge,
+ code_challenge_method: 'S256',
+ prompt: 'consent',
+ })
+
+ const oauthUrl = `${salesforceBaseUrl}/services/oauth2/authorize?${authParams.toString()}`
+
+ logger.info('Initiating Salesforce OAuth:', {
+ orgType,
+ salesforceBaseUrl,
+ redirectUri,
+ returnUrl: returnUrl || 'not specified',
+ })
+
+ const response = NextResponse.redirect(oauthUrl)
+
+ response.cookies.set('salesforce_oauth_state', state, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax',
+ maxAge: 60 * 10,
+ path: '/',
+ })
+
+ response.cookies.set('salesforce_pkce_verifier', verifier, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax',
+ maxAge: 60 * 10,
+ path: '/',
+ })
+
+ response.cookies.set('salesforce_base_url', salesforceBaseUrl, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax',
+ maxAge: 60 * 10,
+ path: '/',
+ })
+
+ if (returnUrl) {
+ response.cookies.set('salesforce_return_url', returnUrl, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax',
+ maxAge: 60 * 10,
+ path: '/',
+ })
+ }
+
+ return response
+ } catch (error) {
+ logger.error('Error initiating Salesforce authorization:', error)
+ return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
+ }
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx
index a72d30e75..c1ad80527 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx
@@ -362,6 +362,13 @@ export function OAuthRequiredModal({
return
}
+ if (providerId === 'salesforce') {
+ // Salesforce requires org type selection (Production/Sandbox/Custom Domain)
+ const returnUrl = encodeURIComponent(window.location.href)
+ window.location.href = `/api/auth/salesforce/authorize?returnUrl=${returnUrl}`
+ return
+ }
+
await client.oauth2.link({
providerId,
callbackURL: window.location.href,
diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts
index 9a9bac3da..d4741a882 100644
--- a/apps/sim/lib/auth/auth.ts
+++ b/apps/sim/lib/auth/auth.ts
@@ -899,56 +899,8 @@ export const auth = betterAuth({
},
},
- // Salesforce provider
- {
- providerId: 'salesforce',
- clientId: env.SALESFORCE_CLIENT_ID as string,
- clientSecret: env.SALESFORCE_CLIENT_SECRET as string,
- authorizationUrl: 'https://login.salesforce.com/services/oauth2/authorize',
- tokenUrl: 'https://login.salesforce.com/services/oauth2/token',
- userInfoUrl: 'https://login.salesforce.com/services/oauth2/userinfo',
- scopes: ['api', 'refresh_token', 'openid', 'offline_access'],
- pkce: true,
- prompt: 'consent',
- accessType: 'offline',
- redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/salesforce`,
- getUserInfo: async (tokens) => {
- try {
- logger.info('Fetching Salesforce user profile')
-
- const response = await fetch(
- 'https://login.salesforce.com/services/oauth2/userinfo',
- {
- headers: {
- Authorization: `Bearer ${tokens.accessToken}`,
- },
- }
- )
-
- if (!response.ok) {
- logger.error('Failed to fetch Salesforce user info', {
- status: response.status,
- })
- throw new Error('Failed to fetch user info')
- }
-
- const data = await response.json()
-
- return {
- id: data.user_id || data.sub,
- name: data.name || 'Salesforce User',
- email: data.email || `salesforce-${data.user_id}@salesforce.com`,
- emailVerified: data.email_verified || true,
- image: data.picture || undefined,
- createdAt: new Date(),
- updatedAt: new Date(),
- }
- } catch (error) {
- logger.error('Error creating Salesforce user profile:', { error })
- return null
- }
- },
- },
+ // Salesforce uses custom OAuth routes at /api/auth/salesforce/authorize
+ // to support Production, Sandbox, and Custom Domain environments
// Supabase provider (unused)
// {
diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts
index 7516732a2..5f858fbde 100644
--- a/apps/sim/lib/oauth/oauth.ts
+++ b/apps/sim/lib/oauth/oauth.ts
@@ -1560,22 +1560,112 @@ function buildAuthRequest(
return { headers, bodyParams }
}
+/**
+ * Attempts to refresh a Salesforce token using multiple endpoints.
+ * Tries the original auth endpoint first (if provided), then production, then sandbox.
+ */
+async function refreshSalesforceToken(
+ refreshToken: string,
+ config: ProviderAuthConfig,
+ authBaseUrl?: string
+): Promise<{ accessToken: string; expiresIn: number; refreshToken: string } | null> {
+ const endpoints: string[] = []
+ if (authBaseUrl) {
+ endpoints.push(`${authBaseUrl}/services/oauth2/token`)
+ }
+ endpoints.push('https://login.salesforce.com/services/oauth2/token')
+ endpoints.push('https://test.salesforce.com/services/oauth2/token')
+
+ const { headers, bodyParams } = buildAuthRequest(config, refreshToken)
+
+ for (const endpoint of endpoints) {
+ try {
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ headers,
+ body: new URLSearchParams(bodyParams).toString(),
+ })
+
+ if (response.ok) {
+ const data = await response.json()
+ const accessToken = data.access_token
+
+ if (!accessToken) {
+ logger.warn('No access token in Salesforce refresh response', { endpoint })
+ continue
+ }
+
+ let newRefreshToken = null
+ if (config.supportsRefreshTokenRotation && data.refresh_token) {
+ newRefreshToken = data.refresh_token
+ logger.info('Received new refresh token from Salesforce')
+ }
+
+ const expiresIn = data.expires_in || data.expiresIn || 7200
+
+ logger.info('Salesforce token refreshed successfully', {
+ endpoint,
+ expiresIn,
+ hasNewRefreshToken: !!newRefreshToken,
+ })
+
+ return {
+ accessToken,
+ expiresIn,
+ refreshToken: newRefreshToken || refreshToken,
+ }
+ }
+
+ // Log failure but try next endpoint
+ const errorText = await response.text()
+ logger.warn('Salesforce token refresh failed for endpoint, trying next', {
+ endpoint,
+ status: response.status,
+ error: errorText.substring(0, 200),
+ })
+ } catch (error) {
+ logger.warn('Salesforce token refresh error for endpoint, trying next', {
+ endpoint,
+ error: error instanceof Error ? error.message : String(error),
+ })
+ }
+ }
+
+ logger.error('Salesforce token refresh failed on all endpoints')
+ return null
+}
+
/**
* Refresh an OAuth token
* This is a server-side utility function to refresh OAuth tokens
* @param providerId The provider ID (e.g., 'google-drive')
* @param refreshToken The refresh token to use
+ * @param idToken Optional idToken field (used by Salesforce to store auth metadata)
* @returns Object containing the new access token and expiration time in seconds, or null if refresh failed
*/
export async function refreshOAuthToken(
providerId: string,
- refreshToken: string
+ refreshToken: string,
+ idToken?: string
): Promise<{ accessToken: string; expiresIn: number; refreshToken: string } | null> {
try {
const provider = providerId.split('-')[0]
const config = getProviderAuthConfig(provider)
+ // Salesforce needs special handling to try multiple endpoints
+ if (provider === 'salesforce') {
+ let authBaseUrl: string | undefined
+ if (idToken?.startsWith('{')) {
+ try {
+ authBaseUrl = JSON.parse(idToken).authBaseUrl
+ } catch {
+ // Not valid JSON, ignore
+ }
+ }
+ return await refreshSalesforceToken(refreshToken, config, authBaseUrl)
+ }
+
const { headers, bodyParams } = buildAuthRequest(config, refreshToken)
const response = await fetch(config.tokenEndpoint, {
diff --git a/apps/sim/tools/salesforce/create_account.ts b/apps/sim/tools/salesforce/create_account.ts
index ed878a93e..05f2d906d 100644
--- a/apps/sim/tools/salesforce/create_account.ts
+++ b/apps/sim/tools/salesforce/create_account.ts
@@ -1,5 +1,6 @@
import { createLogger } from '@/lib/logs/console/logger'
import type { ToolConfig } from '@/tools/types'
+import { getInstanceUrl } from './utils'
const logger = createLogger('SalesforceCreateAccount')
@@ -146,40 +147,7 @@ export const salesforceCreateAccountTool: ToolConfig<
request: {
url: (params) => {
- let instanceUrl = params.instanceUrl
-
- if (!instanceUrl && params.idToken) {
- try {
- const base64Url = params.idToken.split('.')[1]
- const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
- const jsonPayload = decodeURIComponent(
- atob(base64)
- .split('')
- .map((c) => `%${(`00${c.charCodeAt(0).toString(16)}`).slice(-2)}`)
- .join('')
- )
- const decoded = JSON.parse(jsonPayload)
-
- if (decoded.profile) {
- const match = decoded.profile.match(/^(https:\/\/[^/]+)/)
- if (match) {
- instanceUrl = match[1]
- }
- } else if (decoded.sub) {
- const match = decoded.sub.match(/^(https:\/\/[^/]+)/)
- if (match && match[1] !== 'https://login.salesforce.com') {
- instanceUrl = match[1]
- }
- }
- } catch (error) {
- logger.error('Failed to decode Salesforce idToken', { error })
- }
- }
-
- if (!instanceUrl) {
- throw new Error('Salesforce instance URL is required but not provided')
- }
-
+ const instanceUrl = getInstanceUrl(params.idToken, params.instanceUrl)
return `${instanceUrl}/services/data/v59.0/sobjects/Account`
},
method: 'POST',
diff --git a/apps/sim/tools/salesforce/delete_account.ts b/apps/sim/tools/salesforce/delete_account.ts
index b6990ce60..cbf635e6f 100644
--- a/apps/sim/tools/salesforce/delete_account.ts
+++ b/apps/sim/tools/salesforce/delete_account.ts
@@ -1,5 +1,6 @@
import { createLogger } from '@/lib/logs/console/logger'
import type { ToolConfig } from '@/tools/types'
+import { getInstanceUrl } from './utils'
const logger = createLogger('SalesforceDeleteAccount')
@@ -61,40 +62,7 @@ export const salesforceDeleteAccountTool: ToolConfig<
request: {
url: (params) => {
- let instanceUrl = params.instanceUrl
-
- if (!instanceUrl && params.idToken) {
- try {
- const base64Url = params.idToken.split('.')[1]
- const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
- const jsonPayload = decodeURIComponent(
- atob(base64)
- .split('')
- .map((c) => `%${(`00${c.charCodeAt(0).toString(16)}`).slice(-2)}`)
- .join('')
- )
- const decoded = JSON.parse(jsonPayload)
-
- if (decoded.profile) {
- const match = decoded.profile.match(/^(https:\/\/[^/]+)/)
- if (match) {
- instanceUrl = match[1]
- }
- } else if (decoded.sub) {
- const match = decoded.sub.match(/^(https:\/\/[^/]+)/)
- if (match && match[1] !== 'https://login.salesforce.com') {
- instanceUrl = match[1]
- }
- }
- } catch (error) {
- logger.error('Failed to decode Salesforce idToken', { error })
- }
- }
-
- if (!instanceUrl) {
- throw new Error('Salesforce instance URL is required but not provided')
- }
-
+ const instanceUrl = getInstanceUrl(params.idToken, params.instanceUrl)
return `${instanceUrl}/services/data/v59.0/sobjects/Account/${params.accountId}`
},
method: 'DELETE',
diff --git a/apps/sim/tools/salesforce/get_accounts.ts b/apps/sim/tools/salesforce/get_accounts.ts
index 4180fb8fa..1286b68f3 100644
--- a/apps/sim/tools/salesforce/get_accounts.ts
+++ b/apps/sim/tools/salesforce/get_accounts.ts
@@ -4,6 +4,7 @@ import type {
SalesforceGetAccountsResponse,
} from '@/tools/salesforce/types'
import type { ToolConfig } from '@/tools/types'
+import { getInstanceUrl } from './utils'
const logger = createLogger('SalesforceGetAccounts')
@@ -62,39 +63,7 @@ export const salesforceGetAccountsTool: ToolConfig<
request: {
url: (params) => {
- let instanceUrl = params.instanceUrl
-
- if (!instanceUrl && params.idToken) {
- try {
- const base64Url = params.idToken.split('.')[1]
- const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
- const jsonPayload = decodeURIComponent(
- atob(base64)
- .split('')
- .map((c) => `%${(`00${c.charCodeAt(0).toString(16)}`).slice(-2)}`)
- .join('')
- )
- const decoded = JSON.parse(jsonPayload)
-
- if (decoded.profile) {
- const match = decoded.profile.match(/^(https:\/\/[^/]+)/)
- if (match) {
- instanceUrl = match[1]
- }
- } else if (decoded.sub) {
- const match = decoded.sub.match(/^(https:\/\/[^/]+)/)
- if (match && match[1] !== 'https://login.salesforce.com') {
- instanceUrl = match[1]
- }
- }
- } catch (error) {
- logger.error('Failed to decode Salesforce idToken', { error })
- }
- }
-
- if (!instanceUrl) {
- throw new Error('Salesforce instance URL is required but not provided')
- }
+ const instanceUrl = getInstanceUrl(params.idToken, params.instanceUrl)
const limit = params.limit ? Number.parseInt(params.limit) : 100
const fields =
diff --git a/apps/sim/tools/salesforce/update_account.ts b/apps/sim/tools/salesforce/update_account.ts
index c8b8ef8a4..0affc3937 100644
--- a/apps/sim/tools/salesforce/update_account.ts
+++ b/apps/sim/tools/salesforce/update_account.ts
@@ -1,5 +1,6 @@
import { createLogger } from '@/lib/logs/console/logger'
import type { ToolConfig } from '@/tools/types'
+import { getInstanceUrl } from './utils'
const logger = createLogger('SalesforceUpdateAccount')
@@ -152,40 +153,7 @@ export const salesforceUpdateAccountTool: ToolConfig<
request: {
url: (params) => {
- let instanceUrl = params.instanceUrl
-
- if (!instanceUrl && params.idToken) {
- try {
- const base64Url = params.idToken.split('.')[1]
- const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
- const jsonPayload = decodeURIComponent(
- atob(base64)
- .split('')
- .map((c) => `%${(`00${c.charCodeAt(0).toString(16)}`).slice(-2)}`)
- .join('')
- )
- const decoded = JSON.parse(jsonPayload)
-
- if (decoded.profile) {
- const match = decoded.profile.match(/^(https:\/\/[^/]+)/)
- if (match) {
- instanceUrl = match[1]
- }
- } else if (decoded.sub) {
- const match = decoded.sub.match(/^(https:\/\/[^/]+)/)
- if (match && match[1] !== 'https://login.salesforce.com') {
- instanceUrl = match[1]
- }
- }
- } catch (error) {
- logger.error('Failed to decode Salesforce idToken', { error })
- }
- }
-
- if (!instanceUrl) {
- throw new Error('Salesforce instance URL is required but not provided')
- }
-
+ const instanceUrl = getInstanceUrl(params.idToken, params.instanceUrl)
return `${instanceUrl}/services/data/v59.0/sobjects/Account/${params.accountId}`
},
method: 'PATCH',
diff --git a/apps/sim/tools/salesforce/utils.ts b/apps/sim/tools/salesforce/utils.ts
index 74dbaeba3..4f88d3661 100644
--- a/apps/sim/tools/salesforce/utils.ts
+++ b/apps/sim/tools/salesforce/utils.ts
@@ -3,17 +3,47 @@ import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('SalesforceUtils')
/**
- * Extracts Salesforce instance URL from ID token or uses provided instance URL
- * @param idToken - The Salesforce ID token containing instance URL
- * @param instanceUrl - Direct instance URL if provided
- * @returns The Salesforce instance URL
- * @throws Error if instance URL cannot be determined
+ * Salesforce metadata stored in the idToken field
*/
-export function getInstanceUrl(idToken?: string, instanceUrl?: string): string {
- if (instanceUrl) return instanceUrl
- if (idToken) {
+export interface SalesforceMetadata {
+ instanceUrl: string
+ authBaseUrl?: string
+}
+
+/**
+ * Parses the Salesforce metadata from the idToken field
+ * Handles multiple formats for backward compatibility:
+ * 1. JSON object with instanceUrl and authBaseUrl (new format)
+ * 2. Raw URL starting with https:// (legacy format)
+ * 3. JWT token (very old legacy format)
+ *
+ * @param idToken - The value stored in the idToken field
+ * @returns Parsed Salesforce metadata or null if parsing fails
+ */
+export function parseSalesforceMetadata(idToken?: string): SalesforceMetadata | null {
+ if (!idToken) return null
+
+ if (idToken.startsWith('{')) {
try {
- const base64Url = idToken.split('.')[1]
+ const parsed = JSON.parse(idToken)
+ if (parsed.instanceUrl) {
+ return {
+ instanceUrl: parsed.instanceUrl,
+ authBaseUrl: parsed.authBaseUrl,
+ }
+ }
+ } catch {
+ // Not valid JSON, try other formats
+ }
+ }
+
+ if (idToken.startsWith('https://') && idToken.includes('.salesforce.com')) {
+ return { instanceUrl: idToken }
+ }
+
+ try {
+ const base64Url = idToken.split('.')[1]
+ if (base64Url) {
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
const jsonPayload = decodeURIComponent(
atob(base64)
@@ -24,15 +54,36 @@ export function getInstanceUrl(idToken?: string, instanceUrl?: string): string {
const decoded = JSON.parse(jsonPayload)
if (decoded.profile) {
const match = decoded.profile.match(/^(https:\/\/[^/]+)/)
- if (match) return match[1]
+ if (match) return { instanceUrl: match[1] }
} else if (decoded.sub) {
const match = decoded.sub.match(/^(https:\/\/[^/]+)/)
- if (match && match[1] !== 'https://login.salesforce.com') return match[1]
+ if (match && match[1] !== 'https://login.salesforce.com') {
+ return { instanceUrl: match[1] }
+ }
}
- } catch (error) {
- logger.error('Failed to decode Salesforce idToken', { error })
}
+ } catch (error) {
+ logger.error('Failed to decode Salesforce idToken', { error })
}
+
+ return null
+}
+
+/**
+ * Extracts Salesforce instance URL from ID token or uses provided instance URL
+ * @param idToken - The Salesforce ID token (can be JSON metadata, raw URL, or JWT token)
+ * @param instanceUrl - Direct instance URL if provided
+ * @returns The Salesforce instance URL
+ * @throws Error if instance URL cannot be determined
+ */
+export function getInstanceUrl(idToken?: string, instanceUrl?: string): string {
+ if (instanceUrl) return instanceUrl
+
+ const metadata = parseSalesforceMetadata(idToken)
+ if (metadata?.instanceUrl) {
+ return metadata.instanceUrl
+ }
+
throw new Error('Salesforce instance URL is required but not provided')
}