Skip to content

Commit 9da19e8

Browse files
authored
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
1 parent 1720fa8 commit 9da19e8

File tree

12 files changed

+769
-205
lines changed

12 files changed

+769
-205
lines changed

apps/sim/app/api/auth/oauth/utils.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ describe('OAuth Utils', () => {
153153

154154
const result = await refreshTokenIfNeeded('request-id', mockCredential, 'credential-id')
155155

156-
expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token')
156+
expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token', undefined)
157157
expect(mockDb.update).toHaveBeenCalled()
158158
expect(mockDb.set).toHaveBeenCalled()
159159
expect(result).toEqual({ accessToken: 'new-token', refreshed: true })
@@ -228,7 +228,7 @@ describe('OAuth Utils', () => {
228228

229229
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
230230

231-
expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token')
231+
expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token', undefined)
232232
expect(mockDb.update).toHaveBeenCalled()
233233
expect(mockDb.set).toHaveBeenCalled()
234234
expect(token).toBe('new-token')

apps/sim/app/api/auth/oauth/utils.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,11 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
131131
)
132132

133133
try {
134-
// Use the existing refreshOAuthToken function
135-
const refreshResult = await refreshOAuthToken(providerId, credential.refreshToken!)
134+
const refreshResult = await refreshOAuthToken(
135+
providerId,
136+
credential.refreshToken!,
137+
credential.idToken || undefined
138+
)
136139

137140
if (!refreshResult) {
138141
logger.error(`Failed to refresh token for user ${userId}, provider ${providerId}`, {
@@ -217,7 +220,8 @@ export async function refreshAccessTokenIfNeeded(
217220
try {
218221
const refreshedToken = await refreshOAuthToken(
219222
credential.providerId,
220-
credential.refreshToken!
223+
credential.refreshToken!,
224+
credential.idToken || undefined
221225
)
222226

223227
if (!refreshedToken) {
@@ -289,7 +293,11 @@ export async function refreshTokenIfNeeded(
289293
}
290294

291295
try {
292-
const refreshResult = await refreshOAuthToken(credential.providerId, credential.refreshToken!)
296+
const refreshResult = await refreshOAuthToken(
297+
credential.providerId,
298+
credential.refreshToken!,
299+
credential.idToken || undefined
300+
)
293301

294302
if (!refreshResult) {
295303
logger.error(`[${requestId}] Failed to refresh token for credential`)
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import { db } from '@sim/db'
2+
import { account } from '@sim/db/schema'
3+
import { and, eq } from 'drizzle-orm'
4+
import { type NextRequest, NextResponse } from 'next/server'
5+
import { getSession } from '@/lib/auth'
6+
import { env } from '@/lib/core/config/env'
7+
import { getBaseUrl } from '@/lib/core/utils/urls'
8+
import { createLogger } from '@/lib/logs/console/logger'
9+
import { safeAccountInsert } from '@/app/api/auth/oauth/utils'
10+
11+
const logger = createLogger('SalesforceCallback')
12+
13+
export const dynamic = 'force-dynamic'
14+
15+
export async function GET(request: NextRequest) {
16+
const baseUrl = getBaseUrl()
17+
18+
try {
19+
const session = await getSession()
20+
if (!session?.user?.id) {
21+
logger.warn('Unauthorized attempt to complete Salesforce OAuth')
22+
return NextResponse.redirect(`${baseUrl}/workspace?error=unauthorized`)
23+
}
24+
25+
const { searchParams } = request.nextUrl
26+
const code = searchParams.get('code')
27+
const state = searchParams.get('state')
28+
const error = searchParams.get('error')
29+
const errorDescription = searchParams.get('error_description')
30+
31+
if (error) {
32+
logger.error('Salesforce OAuth error:', { error, errorDescription })
33+
return NextResponse.redirect(
34+
`${baseUrl}/workspace?error=salesforce_oauth_error&message=${encodeURIComponent(errorDescription || error)}`
35+
)
36+
}
37+
38+
const storedState = request.cookies.get('salesforce_oauth_state')?.value
39+
const storedVerifier = request.cookies.get('salesforce_pkce_verifier')?.value
40+
const storedBaseUrl = request.cookies.get('salesforce_base_url')?.value
41+
const returnUrl = request.cookies.get('salesforce_return_url')?.value
42+
43+
const clientId = env.SALESFORCE_CLIENT_ID
44+
const clientSecret = env.SALESFORCE_CLIENT_SECRET
45+
46+
if (!clientId || !clientSecret) {
47+
logger.error('Salesforce credentials not configured')
48+
return NextResponse.redirect(`${baseUrl}/workspace?error=salesforce_config_error`)
49+
}
50+
51+
if (!state || state !== storedState) {
52+
logger.error('State mismatch in Salesforce OAuth callback', {
53+
receivedState: state,
54+
storedState: storedState ? 'present' : 'missing',
55+
})
56+
return NextResponse.redirect(`${baseUrl}/workspace?error=salesforce_state_mismatch`)
57+
}
58+
59+
if (!code) {
60+
logger.error('No authorization code received from Salesforce')
61+
return NextResponse.redirect(`${baseUrl}/workspace?error=salesforce_no_code`)
62+
}
63+
64+
if (!storedVerifier || !storedBaseUrl) {
65+
logger.error('Missing PKCE verifier or base URL')
66+
return NextResponse.redirect(`${baseUrl}/workspace?error=salesforce_missing_data`)
67+
}
68+
69+
const tokenUrl = `${storedBaseUrl}/services/oauth2/token`
70+
const redirectUri = `${baseUrl}/api/auth/oauth2/callback/salesforce`
71+
72+
const tokenParams = new URLSearchParams({
73+
grant_type: 'authorization_code',
74+
code: code,
75+
client_id: clientId,
76+
client_secret: clientSecret,
77+
redirect_uri: redirectUri,
78+
code_verifier: storedVerifier,
79+
})
80+
81+
const tokenResponse = await fetch(tokenUrl, {
82+
method: 'POST',
83+
headers: {
84+
'Content-Type': 'application/x-www-form-urlencoded',
85+
},
86+
body: tokenParams.toString(),
87+
})
88+
89+
if (!tokenResponse.ok) {
90+
const errorText = await tokenResponse.text()
91+
logger.error('Failed to exchange code for token:', {
92+
status: tokenResponse.status,
93+
body: errorText,
94+
tokenUrl,
95+
})
96+
return NextResponse.redirect(`${baseUrl}/workspace?error=salesforce_token_error`)
97+
}
98+
99+
const tokenData = await tokenResponse.json()
100+
const accessToken = tokenData.access_token
101+
const refreshToken = tokenData.refresh_token
102+
const instanceUrl = tokenData.instance_url
103+
const scope = tokenData.scope
104+
// Salesforce returns expires_in in seconds, default to 7200 (2 hours) if not provided
105+
const expiresIn = tokenData.expires_in ? Number(tokenData.expires_in) : 7200
106+
107+
logger.info('Salesforce token exchange successful:', {
108+
hasAccessToken: !!accessToken,
109+
hasRefreshToken: !!refreshToken,
110+
instanceUrl,
111+
scope,
112+
expiresIn,
113+
})
114+
115+
if (!accessToken) {
116+
logger.error('No access token in Salesforce response')
117+
return NextResponse.redirect(`${baseUrl}/workspace?error=salesforce_no_token`)
118+
}
119+
120+
if (!instanceUrl) {
121+
logger.error('No instance URL in Salesforce response')
122+
return NextResponse.redirect(`${baseUrl}/workspace?error=salesforce_no_instance`)
123+
}
124+
125+
let userId = 'unknown'
126+
let userEmail = ''
127+
try {
128+
const userInfoUrl = `${instanceUrl}/services/oauth2/userinfo`
129+
const userInfoResponse = await fetch(userInfoUrl, {
130+
headers: {
131+
Authorization: `Bearer ${accessToken}`,
132+
},
133+
})
134+
if (userInfoResponse.ok) {
135+
const userInfo = await userInfoResponse.json()
136+
userId = userInfo.user_id || userInfo.sub || 'unknown'
137+
userEmail = userInfo.email || ''
138+
}
139+
} catch (userInfoError) {
140+
logger.warn('Failed to fetch Salesforce user info:', userInfoError)
141+
}
142+
143+
const existing = await db.query.account.findFirst({
144+
where: and(eq(account.userId, session.user.id), eq(account.providerId, 'salesforce')),
145+
})
146+
147+
const now = new Date()
148+
const expiresAt = new Date(now.getTime() + expiresIn * 1000)
149+
150+
/**
151+
* Store both instanceUrl (API endpoint) and authBaseUrl (OAuth endpoint) in idToken field.
152+
* - instanceUrl: Used for API calls (e.g., https://na1.salesforce.com)
153+
* - authBaseUrl: Used for token refresh (e.g., https://login.salesforce.com or custom domain)
154+
* This is a non-standard use of the idToken field, but necessary for Salesforce's
155+
* multi-endpoint OAuth architecture.
156+
*/
157+
const salesforceMetadata = JSON.stringify({
158+
instanceUrl: instanceUrl,
159+
authBaseUrl: storedBaseUrl,
160+
})
161+
162+
const accountData = {
163+
accessToken: accessToken,
164+
refreshToken: refreshToken || null,
165+
accountId: userId,
166+
scope: scope || '',
167+
updatedAt: now,
168+
accessTokenExpiresAt: expiresAt,
169+
idToken: salesforceMetadata,
170+
}
171+
172+
if (existing) {
173+
await db.update(account).set(accountData).where(eq(account.id, existing.id))
174+
logger.info('Updated existing Salesforce account', { accountId: existing.id })
175+
} else {
176+
await safeAccountInsert(
177+
{
178+
id: `salesforce_${session.user.id}_${Date.now()}`,
179+
userId: session.user.id,
180+
providerId: 'salesforce',
181+
accountId: accountData.accountId,
182+
accessToken: accountData.accessToken,
183+
refreshToken: accountData.refreshToken || undefined,
184+
scope: accountData.scope,
185+
idToken: accountData.idToken,
186+
accessTokenExpiresAt: accountData.accessTokenExpiresAt,
187+
createdAt: now,
188+
updatedAt: now,
189+
},
190+
{ provider: 'Salesforce', identifier: userEmail || userId }
191+
)
192+
}
193+
194+
let redirectUrl = `${baseUrl}/workspace`
195+
if (returnUrl) {
196+
try {
197+
const returnUrlObj = new URL(returnUrl, baseUrl)
198+
if (returnUrlObj.origin === new URL(baseUrl).origin) {
199+
redirectUrl = returnUrl
200+
} else {
201+
logger.warn('Invalid returnUrl origin, ignoring', { returnUrl, baseUrl })
202+
}
203+
} catch {
204+
logger.warn('Invalid returnUrl format, ignoring', { returnUrl })
205+
}
206+
}
207+
const finalUrl = new URL(redirectUrl, baseUrl)
208+
finalUrl.searchParams.set('salesforce_connected', 'true')
209+
210+
const response = NextResponse.redirect(finalUrl.toString())
211+
response.cookies.delete('salesforce_oauth_state')
212+
response.cookies.delete('salesforce_pkce_verifier')
213+
response.cookies.delete('salesforce_base_url')
214+
response.cookies.delete('salesforce_return_url')
215+
216+
return response
217+
} catch (error) {
218+
logger.error('Error in Salesforce OAuth callback:', error)
219+
return NextResponse.redirect(`${baseUrl}/workspace?error=salesforce_callback_error`)
220+
}
221+
}

0 commit comments

Comments
 (0)