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') }