mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-07 22:24:06 -05:00
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:
@@ -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')
|
||||
|
||||
@@ -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`)
|
||||
|
||||
221
apps/sim/app/api/auth/oauth2/callback/salesforce/route.ts
Normal file
221
apps/sim/app/api/auth/oauth2/callback/salesforce/route.ts
Normal 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`)
|
||||
}
|
||||
}
|
||||
362
apps/sim/app/api/auth/salesforce/authorize/route.ts
Normal file
362
apps/sim/app/api/auth/salesforce/authorize/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
// {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user