Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/sim/app/api/auth/oauth/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down Expand Up @@ -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')
Expand Down
16 changes: 12 additions & 4 deletions apps/sim/app/api/auth/oauth/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`, {
Expand Down Expand Up @@ -217,7 +220,8 @@ export async function refreshAccessTokenIfNeeded(
try {
const refreshedToken = await refreshOAuthToken(
credential.providerId,
credential.refreshToken!
credential.refreshToken!,
credential.idToken || undefined
)

if (!refreshedToken) {
Expand Down Expand Up @@ -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`)
Expand Down
221 changes: 221 additions & 0 deletions apps/sim/app/api/auth/oauth2/callback/salesforce/route.ts
Original file line number Diff line number Diff line change
@@ -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`)
}
}
Loading
Loading