Skip to content

Commit 2e133f0

Browse files
author
aadamgough
committed
first push
1 parent 948b657 commit 2e133f0

File tree

24 files changed

+2538
-6
lines changed

24 files changed

+2538
-6
lines changed

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

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
6767
accessToken: account.accessToken,
6868
refreshToken: account.refreshToken,
6969
accessTokenExpiresAt: account.accessTokenExpiresAt,
70+
accountId: account.accountId,
71+
providerId: account.providerId,
7072
})
7173
.from(account)
7274
.where(and(eq(account.userId, userId), eq(account.providerId, providerId)))
@@ -93,8 +95,14 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
9395
)
9496

9597
try {
98+
// Extract account URL from accountId for Snowflake
99+
let metadata: { accountUrl?: string } | undefined
100+
if (providerId === 'snowflake' && credential.accountId) {
101+
metadata = { accountUrl: credential.accountId }
102+
}
103+
96104
// Use the existing refreshOAuthToken function
97-
const refreshResult = await refreshOAuthToken(providerId, credential.refreshToken!)
105+
const refreshResult = await refreshOAuthToken(providerId, credential.refreshToken!, metadata)
98106

99107
if (!refreshResult) {
100108
logger.error(`Failed to refresh token for user ${userId}, provider ${providerId}`, {
@@ -177,9 +185,16 @@ export async function refreshAccessTokenIfNeeded(
177185
if (shouldRefresh) {
178186
logger.info(`[${requestId}] Token expired, attempting to refresh for credential`)
179187
try {
188+
// Extract account URL from accountId for Snowflake
189+
let metadata: { accountUrl?: string } | undefined
190+
if (credential.providerId === 'snowflake' && credential.accountId) {
191+
metadata = { accountUrl: credential.accountId }
192+
}
193+
180194
const refreshedToken = await refreshOAuthToken(
181195
credential.providerId,
182-
credential.refreshToken!
196+
credential.refreshToken!,
197+
metadata
183198
)
184199

185200
if (!refreshedToken) {
@@ -251,7 +266,13 @@ export async function refreshTokenIfNeeded(
251266
}
252267

253268
try {
254-
const refreshResult = await refreshOAuthToken(credential.providerId, credential.refreshToken!)
269+
// Extract account URL from accountId for Snowflake
270+
let metadata: { accountUrl?: string } | undefined
271+
if (credential.providerId === 'snowflake' && credential.accountId) {
272+
metadata = { accountUrl: credential.accountId }
273+
}
274+
275+
const refreshResult = await refreshOAuthToken(credential.providerId, credential.refreshToken!, metadata)
255276

256277
if (!refreshResult) {
257278
logger.error(`[${requestId}] Failed to refresh token for credential`)
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { type NextRequest, NextResponse } from 'next/server'
2+
import { getSession } from '@/lib/auth'
3+
import { env } from '@/lib/env'
4+
import { createLogger } from '@/lib/logs/console/logger'
5+
import { getBaseUrl } from '@/lib/urls/utils'
6+
import { generateCodeChallenge, generateCodeVerifier } from '@/lib/oauth/pkce'
7+
8+
const logger = createLogger('SnowflakeAuthorize')
9+
10+
export const dynamic = 'force-dynamic'
11+
12+
/**
13+
* Initiates Snowflake OAuth flow
14+
* Requires accountUrl as query parameter
15+
*/
16+
export async function GET(request: NextRequest) {
17+
try {
18+
const session = await getSession()
19+
if (!session?.user?.id) {
20+
logger.warn('Unauthorized Snowflake OAuth attempt')
21+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
22+
}
23+
24+
const { searchParams } = new URL(request.url)
25+
const accountUrl = searchParams.get('accountUrl')
26+
27+
if (!accountUrl) {
28+
logger.error('Missing accountUrl parameter')
29+
return NextResponse.json(
30+
{ error: 'accountUrl parameter is required' },
31+
{ status: 400 }
32+
)
33+
}
34+
35+
const clientId = env.SNOWFLAKE_CLIENT_ID
36+
const clientSecret = env.SNOWFLAKE_CLIENT_SECRET
37+
38+
if (!clientId || !clientSecret) {
39+
logger.error('Snowflake OAuth credentials not configured')
40+
return NextResponse.json(
41+
{ error: 'Snowflake OAuth not configured' },
42+
{ status: 500 }
43+
)
44+
}
45+
46+
// Parse and clean the account URL
47+
let cleanAccountUrl = accountUrl.replace(/^https?:\/\//, '')
48+
cleanAccountUrl = cleanAccountUrl.replace(/\/$/, '')
49+
if (!cleanAccountUrl.includes('snowflakecomputing.com')) {
50+
cleanAccountUrl = `${cleanAccountUrl}.snowflakecomputing.com`
51+
}
52+
53+
const baseUrl = getBaseUrl()
54+
const redirectUri = `${baseUrl}/api/auth/snowflake/callback`
55+
56+
// Generate PKCE values
57+
const codeVerifier = generateCodeVerifier()
58+
const codeChallenge = await generateCodeChallenge(codeVerifier)
59+
60+
61+
const state = Buffer.from(
62+
JSON.stringify({
63+
userId: session.user.id,
64+
accountUrl: cleanAccountUrl,
65+
timestamp: Date.now(),
66+
codeVerifier,
67+
})
68+
).toString('base64url')
69+
70+
// Construct Snowflake-specific authorization URL
71+
const authUrl = new URL(`https://${cleanAccountUrl}/oauth/authorize`)
72+
authUrl.searchParams.set('client_id', clientId)
73+
authUrl.searchParams.set('response_type', 'code')
74+
authUrl.searchParams.set('redirect_uri', redirectUri)
75+
// Add scope parameter to specify a safe role (not ACCOUNTADMIN or SECURITYADMIN)
76+
authUrl.searchParams.set('scope', 'refresh_token session:role:PUBLIC')
77+
authUrl.searchParams.set('state', state)
78+
// Add PKCE parameters for security and compatibility with OAUTH_ENFORCE_PKCE
79+
authUrl.searchParams.set('code_challenge', codeChallenge)
80+
authUrl.searchParams.set('code_challenge_method', 'S256')
81+
82+
logger.info('Initiating Snowflake OAuth flow (CONFIDENTIAL client with PKCE)', {
83+
userId: session.user.id,
84+
accountUrl: cleanAccountUrl,
85+
authUrl: authUrl.toString(),
86+
redirectUri,
87+
clientId,
88+
hasClientSecret: !!clientSecret,
89+
hasPkce: true,
90+
parametersCount: authUrl.searchParams.toString().length,
91+
})
92+
93+
logger.info('Authorization URL parameters:', {
94+
client_id: authUrl.searchParams.get('client_id'),
95+
response_type: authUrl.searchParams.get('response_type'),
96+
redirect_uri: authUrl.searchParams.get('redirect_uri'),
97+
state_length: authUrl.searchParams.get('state')?.length,
98+
scope: authUrl.searchParams.get('scope'),
99+
has_pkce: authUrl.searchParams.has('code_challenge'),
100+
code_challenge_method: authUrl.searchParams.get('code_challenge_method'),
101+
})
102+
103+
return NextResponse.redirect(authUrl.toString())
104+
} catch (error) {
105+
logger.error('Error initiating Snowflake authorization:', error)
106+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
107+
}
108+
}
109+
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { and, eq } from 'drizzle-orm'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { getSession } from '@/lib/auth'
4+
import { env } from '@/lib/env'
5+
import { createLogger } from '@/lib/logs/console/logger'
6+
import { getBaseUrl } from '@/lib/urls/utils'
7+
import { db } from '@/../../packages/db'
8+
import { account } from '@/../../packages/db/schema'
9+
10+
const logger = createLogger('SnowflakeCallback')
11+
12+
export const dynamic = 'force-dynamic'
13+
14+
/**
15+
* Handles Snowflake OAuth callback
16+
*/
17+
export async function GET(request: NextRequest) {
18+
try {
19+
const session = await getSession()
20+
if (!session?.user?.id) {
21+
logger.warn('Unauthorized Snowflake OAuth callback')
22+
return NextResponse.redirect(`${getBaseUrl()}/workspace?error=unauthorized`)
23+
}
24+
25+
const { searchParams } = new URL(request.url)
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+
// Handle OAuth errors
32+
if (error) {
33+
logger.error('Snowflake OAuth error', { error, errorDescription })
34+
return NextResponse.redirect(
35+
`${getBaseUrl()}/workspace?error=snowflake_${error}&description=${encodeURIComponent(errorDescription || '')}`
36+
)
37+
}
38+
39+
if (!code || !state) {
40+
logger.error('Missing code or state in callback')
41+
return NextResponse.redirect(`${getBaseUrl()}/workspace?error=snowflake_invalid_callback`)
42+
}
43+
44+
// Decode state to get account URL and code verifier
45+
let stateData: {
46+
userId: string
47+
accountUrl: string
48+
timestamp: number
49+
codeVerifier: string
50+
}
51+
52+
try {
53+
stateData = JSON.parse(Buffer.from(state, 'base64url').toString())
54+
logger.info('Decoded state successfully', {
55+
userId: stateData.userId,
56+
accountUrl: stateData.accountUrl,
57+
age: Date.now() - stateData.timestamp,
58+
hasCodeVerifier: !!stateData.codeVerifier,
59+
})
60+
} catch (e) {
61+
logger.error('Invalid state parameter', { error: e, state })
62+
return NextResponse.redirect(`${getBaseUrl()}/workspace?error=snowflake_invalid_state`)
63+
}
64+
65+
// Verify the user matches
66+
if (stateData.userId !== session.user.id) {
67+
logger.error('User ID mismatch in state', {
68+
stateUserId: stateData.userId,
69+
sessionUserId: session.user.id,
70+
})
71+
return NextResponse.redirect(`${getBaseUrl()}/workspace?error=snowflake_user_mismatch`)
72+
}
73+
74+
// Verify state is not too old (15 minutes)
75+
if (Date.now() - stateData.timestamp > 15 * 60 * 1000) {
76+
logger.error('State expired', {
77+
age: Date.now() - stateData.timestamp,
78+
})
79+
return NextResponse.redirect(`${getBaseUrl()}/workspace?error=snowflake_state_expired`)
80+
}
81+
82+
const clientId = env.SNOWFLAKE_CLIENT_ID
83+
const clientSecret = env.SNOWFLAKE_CLIENT_SECRET
84+
85+
if (!clientId || !clientSecret) {
86+
logger.error('Snowflake OAuth credentials not configured')
87+
return NextResponse.redirect(`${getBaseUrl()}/workspace?error=snowflake_not_configured`)
88+
}
89+
90+
// Exchange authorization code for tokens
91+
const tokenUrl = `https://${stateData.accountUrl}/oauth/token-request`
92+
const redirectUri = `${getBaseUrl()}/api/auth/snowflake/callback`
93+
94+
const tokenParams = new URLSearchParams({
95+
grant_type: 'authorization_code',
96+
code,
97+
redirect_uri: redirectUri,
98+
client_id: clientId,
99+
client_secret: clientSecret,
100+
code_verifier: stateData.codeVerifier,
101+
})
102+
103+
logger.info('Exchanging authorization code for tokens (with PKCE)', {
104+
tokenUrl,
105+
redirectUri,
106+
clientId,
107+
hasCode: !!code,
108+
hasClientSecret: !!clientSecret,
109+
hasCodeVerifier: !!stateData.codeVerifier,
110+
paramsLength: tokenParams.toString().length,
111+
})
112+
113+
const tokenResponse = await fetch(tokenUrl, {
114+
method: 'POST',
115+
headers: {
116+
'Content-Type': 'application/x-www-form-urlencoded',
117+
},
118+
body: tokenParams.toString(),
119+
})
120+
121+
if (!tokenResponse.ok) {
122+
const errorText = await tokenResponse.text()
123+
logger.error('Failed to exchange code for token', {
124+
status: tokenResponse.status,
125+
statusText: tokenResponse.statusText,
126+
error: errorText,
127+
tokenUrl,
128+
redirectUri,
129+
})
130+
131+
// Try to parse error as JSON for better diagnostics
132+
try {
133+
const errorJson = JSON.parse(errorText)
134+
logger.error('Snowflake error details:', errorJson)
135+
} catch (e) {
136+
logger.error('Error text (not JSON):', errorText)
137+
}
138+
139+
return NextResponse.redirect(
140+
`${getBaseUrl()}/workspace?error=snowflake_token_exchange_failed&details=${encodeURIComponent(errorText)}`
141+
)
142+
}
143+
144+
const tokens = await tokenResponse.json()
145+
146+
logger.info('Token exchange for Snowflake successful', {
147+
hasAccessToken: !!tokens.access_token,
148+
hasRefreshToken: !!tokens.refresh_token,
149+
expiresIn: tokens.expires_in,
150+
scope: tokens.scope,
151+
})
152+
153+
if (!tokens.access_token) {
154+
logger.error('No access token in response', { tokens })
155+
return NextResponse.redirect(`${getBaseUrl()}/workspace?error=snowflake_no_access_token`)
156+
}
157+
158+
// Store the account and tokens in the database
159+
const existing = await db.query.account.findFirst({
160+
where: and(
161+
eq(account.userId, session.user.id),
162+
eq(account.providerId, 'snowflake')
163+
),
164+
})
165+
166+
const now = new Date()
167+
const expiresAt = tokens.expires_in
168+
? new Date(now.getTime() + tokens.expires_in * 1000)
169+
: new Date(now.getTime() + 10 * 60 * 1000) // Default 10 minutes
170+
171+
const accountData = {
172+
userId: session.user.id,
173+
providerId: 'snowflake',
174+
accountId: stateData.accountUrl, // Store the Snowflake account URL here
175+
accessToken: tokens.access_token,
176+
refreshToken: tokens.refresh_token || null,
177+
accessTokenExpiresAt: expiresAt,
178+
scope: tokens.scope || null,
179+
updatedAt: now,
180+
}
181+
182+
if (existing) {
183+
await db
184+
.update(account)
185+
.set(accountData)
186+
.where(eq(account.id, existing.id))
187+
188+
logger.info('Updated existing Snowflake account', {
189+
userId: session.user.id,
190+
accountUrl: stateData.accountUrl,
191+
})
192+
} else {
193+
await db.insert(account).values({
194+
...accountData,
195+
id: `snowflake_${session.user.id}_${Date.now()}`,
196+
createdAt: now,
197+
})
198+
199+
logger.info('Created new Snowflake account', {
200+
userId: session.user.id,
201+
accountUrl: stateData.accountUrl,
202+
})
203+
}
204+
205+
return NextResponse.redirect(`${getBaseUrl()}/workspace?snowflake_connected=true`)
206+
} catch (error) {
207+
logger.error('Error in Snowflake callback:', error)
208+
return NextResponse.redirect(`${getBaseUrl()}/workspace?error=snowflake_callback_failed`)
209+
}
210+
}
211+

0 commit comments

Comments
 (0)