fix(salesforce): updated to more flexible oauth that allows production, developer, and custom domain salesforce orgs (#2441) (#2444)

* fix(oauth): updated oauth providers that had unstable reference IDs leading to duplicate oauth records (#2441)

* fix(oauth): updated oauth providers that had unstable reference IDs leading to duplicate oauth records

* ack PR comments

* ack PR comments

* cleanup salesforce refresh logic

* ack more PR comments
This commit is contained in:
Waleed
2025-12-18 11:39:28 -08:00
committed by GitHub
parent 1720fa8749
commit 9da19e84b7
12 changed files with 769 additions and 205 deletions

View File

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

View File

@@ -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`)

View File

@@ -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`)
}
}

View File

@@ -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(
`<!DOCTYPE html>
<html>
<head>
<title>Connect Salesforce</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #00A1E0 0%, #032D60 100%);
}
.container {
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
text-align: center;
max-width: 420px;
width: 90%;
}
h2 {
color: #111827;
margin: 0 0 0.5rem 0;
}
p {
color: #6b7280;
margin: 0 0 1.5rem 0;
font-size: 0.95rem;
}
.options {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.option {
display: flex;
align-items: center;
padding: 1rem;
border: 2px solid #e5e7eb;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
text-align: left;
}
.option:hover {
border-color: #00A1E0;
background: #f0f9ff;
}
.option.selected {
border-color: #00A1E0;
background: #e0f2fe;
}
.option input {
margin-right: 12px;
width: 18px;
height: 18px;
accent-color: #00A1E0;
}
.option-content {
flex: 1;
}
.option-title {
font-weight: 600;
color: #111827;
font-size: 0.95rem;
}
.option-desc {
font-size: 0.8rem;
color: #6b7280;
margin-top: 2px;
}
.custom-domain {
margin-top: 0.75rem;
display: none;
}
.custom-domain.visible {
display: block;
}
.custom-domain input {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 1rem;
box-sizing: border-box;
}
.custom-domain input:focus {
outline: none;
border-color: #00A1E0;
box-shadow: 0 0 0 3px rgba(0, 161, 224, 0.2);
}
button {
width: 100%;
padding: 0.875rem;
background: #00A1E0;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
font-weight: 600;
transition: background 0.2s;
}
button:hover {
background: #0082b3;
}
button:disabled {
background: #9ca3af;
cursor: not-allowed;
}
.help {
font-size: 0.8rem;
color: #9ca3af;
margin-top: 1rem;
}
</style>
</head>
<body>
<div class="container">
<h2>Connect Your Salesforce Account</h2>
<p>Select your Salesforce environment type</p>
<form onsubmit="handleSubmit(event)">
<div class="options">
<label class="option" onclick="selectOption('production')">
<input type="radio" name="orgType" value="production" id="production">
<div class="option-content">
<div class="option-title">Production</div>
<div class="option-desc">Live Salesforce org with real data</div>
</div>
</label>
<label class="option" onclick="selectOption('sandbox')">
<input type="radio" name="orgType" value="sandbox" id="sandbox">
<div class="option-content">
<div class="option-title">Sandbox / Developer Edition</div>
<div class="option-desc">Test environment or free developer org</div>
</div>
</label>
<label class="option" onclick="selectOption('custom')">
<input type="radio" name="orgType" value="custom" id="custom">
<div class="option-content">
<div class="option-title">Custom Domain (My Domain)</div>
<div class="option-desc">Use your organization's custom Salesforce URL</div>
</div>
</label>
</div>
<div class="custom-domain" id="customDomainInput">
<input
type="text"
id="customDomain"
placeholder="mycompany.my.salesforce.com"
pattern="[a-zA-Z0-9-]+\\.my\\.salesforce\\.com"
/>
</div>
<button type="submit" id="submitBtn" disabled>Connect to Salesforce</button>
</form>
<p class="help">Your data stays secure with Salesforce's OAuth 2.0</p>
</div>
<script>
const returnUrl = ${JSON.stringify(returnUrlParam)};
let selectedType = null;
function selectOption(type) {
selectedType = type;
document.querySelectorAll('.option').forEach(opt => opt.classList.remove('selected'));
document.querySelector('input[value="' + type + '"]').closest('.option').classList.add('selected');
document.getElementById('submitBtn').disabled = false;
const customInput = document.getElementById('customDomainInput');
if (type === 'custom') {
customInput.classList.add('visible');
document.getElementById('customDomain').required = true;
} else {
customInput.classList.remove('visible');
document.getElementById('customDomain').required = false;
}
}
function handleSubmit(e) {
e.preventDefault();
let url = window.location.pathname + '?orgType=' + selectedType;
if (selectedType === 'custom') {
let domain = document.getElementById('customDomain').value.trim().toLowerCase();
domain = domain.replace('https://', '').replace('http://', '');
// Remove any trailing slashes and common salesforce domain suffixes
domain = domain.replace(/\/+$/, '');
domain = domain.replace(/\.(my\.)?salesforce\.com$/i, '');
// Add the correct suffix
domain = domain + '.my.salesforce.com';
url += '&customDomain=' + encodeURIComponent(domain);
}
if (returnUrl) {
url += '&returnUrl=' + returnUrl;
}
window.location.href = url;
}
</script>
</body>
</html>`,
{
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 })
}
}

View File

@@ -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,

View File

@@ -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)
// {

View File

@@ -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, {

View File

@@ -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',

View File

@@ -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',

View File

@@ -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 =

View File

@@ -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',

View File

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