mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
client id and secret for individual projects, no more single client and secret
This commit is contained in:
@@ -69,6 +69,7 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
|
||||
accessTokenExpiresAt: account.accessTokenExpiresAt,
|
||||
accountId: account.accountId,
|
||||
providerId: account.providerId,
|
||||
password: account.password, // Include password field for Snowflake OAuth credentials
|
||||
})
|
||||
.from(account)
|
||||
.where(and(eq(account.userId, userId), eq(account.providerId, providerId)))
|
||||
@@ -95,10 +96,21 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
|
||||
)
|
||||
|
||||
try {
|
||||
// Extract account URL from accountId for Snowflake
|
||||
let metadata: { accountUrl?: string } | undefined
|
||||
// Extract account URL and OAuth credentials for Snowflake
|
||||
let metadata: { accountUrl?: string; clientId?: string; clientSecret?: string } | undefined
|
||||
if (providerId === 'snowflake' && credential.accountId) {
|
||||
metadata = { accountUrl: credential.accountId }
|
||||
|
||||
// Extract clientId and clientSecret from the password field (stored as JSON)
|
||||
if (credential.password) {
|
||||
try {
|
||||
const oauthCredentials = JSON.parse(credential.password)
|
||||
metadata.clientId = oauthCredentials.clientId
|
||||
metadata.clientSecret = oauthCredentials.clientSecret
|
||||
} catch (e) {
|
||||
logger.error('Failed to parse Snowflake OAuth credentials', { error: e })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use the existing refreshOAuthToken function
|
||||
@@ -185,10 +197,21 @@ export async function refreshAccessTokenIfNeeded(
|
||||
if (shouldRefresh) {
|
||||
logger.info(`[${requestId}] Token expired, attempting to refresh for credential`)
|
||||
try {
|
||||
// Extract account URL from accountId for Snowflake
|
||||
let metadata: { accountUrl?: string } | undefined
|
||||
// Extract account URL and OAuth credentials for Snowflake
|
||||
let metadata: { accountUrl?: string; clientId?: string; clientSecret?: string } | undefined
|
||||
if (credential.providerId === 'snowflake' && credential.accountId) {
|
||||
metadata = { accountUrl: credential.accountId }
|
||||
|
||||
// Extract clientId and clientSecret from the password field (stored as JSON)
|
||||
if (credential.password) {
|
||||
try {
|
||||
const oauthCredentials = JSON.parse(credential.password)
|
||||
metadata.clientId = oauthCredentials.clientId
|
||||
metadata.clientSecret = oauthCredentials.clientSecret
|
||||
} catch (e) {
|
||||
logger.error('Failed to parse Snowflake OAuth credentials', { error: e })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const refreshedToken = await refreshOAuthToken(
|
||||
@@ -266,10 +289,21 @@ export async function refreshTokenIfNeeded(
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract account URL from accountId for Snowflake
|
||||
let metadata: { accountUrl?: string } | undefined
|
||||
// Extract account URL and OAuth credentials for Snowflake
|
||||
let metadata: { accountUrl?: string; clientId?: string; clientSecret?: string } | undefined
|
||||
if (credential.providerId === 'snowflake' && credential.accountId) {
|
||||
metadata = { accountUrl: credential.accountId }
|
||||
|
||||
// Extract clientId and clientSecret from the password field (stored as JSON)
|
||||
if (credential.password) {
|
||||
try {
|
||||
const oauthCredentials = JSON.parse(credential.password)
|
||||
metadata.clientId = oauthCredentials.clientId
|
||||
metadata.clientSecret = oauthCredentials.clientSecret
|
||||
} catch (e) {
|
||||
logger.error('Failed to parse Snowflake OAuth credentials', { error: e })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const refreshResult = await refreshOAuthToken(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { generateCodeChallenge, generateCodeVerifier } from '@/lib/oauth/pkce'
|
||||
import { getBaseUrl } from '@/lib/urls/utils'
|
||||
@@ -11,9 +10,9 @@ export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* Initiates Snowflake OAuth flow
|
||||
* Requires accountUrl as query parameter
|
||||
* Expects credentials to be posted in the request body (accountUrl, clientId, clientSecret)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
@@ -21,20 +20,19 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const accountUrl = searchParams.get('accountUrl')
|
||||
const body = await request.json()
|
||||
const { accountUrl, clientId, clientSecret } = body
|
||||
|
||||
if (!accountUrl) {
|
||||
logger.error('Missing accountUrl parameter')
|
||||
return NextResponse.json({ error: 'accountUrl parameter is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const clientId = env.SNOWFLAKE_CLIENT_ID
|
||||
const clientSecret = env.SNOWFLAKE_CLIENT_SECRET
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
logger.error('Snowflake OAuth credentials not configured')
|
||||
return NextResponse.json({ error: 'Snowflake OAuth not configured' }, { status: 500 })
|
||||
if (!accountUrl || !clientId || !clientSecret) {
|
||||
logger.error('Missing required Snowflake OAuth parameters', {
|
||||
hasAccountUrl: !!accountUrl,
|
||||
hasClientId: !!clientId,
|
||||
hasClientSecret: !!clientSecret,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'accountUrl, clientId, and clientSecret are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Parse and clean the account URL
|
||||
@@ -51,10 +49,13 @@ export async function GET(request: NextRequest) {
|
||||
const codeVerifier = generateCodeVerifier()
|
||||
const codeChallenge = await generateCodeChallenge(codeVerifier)
|
||||
|
||||
// Store user-provided credentials in the state (will be used in callback)
|
||||
const state = Buffer.from(
|
||||
JSON.stringify({
|
||||
userId: session.user.id,
|
||||
accountUrl: cleanAccountUrl,
|
||||
clientId,
|
||||
clientSecret,
|
||||
timestamp: Date.now(),
|
||||
codeVerifier,
|
||||
})
|
||||
@@ -72,28 +73,18 @@ export async function GET(request: NextRequest) {
|
||||
authUrl.searchParams.set('code_challenge', codeChallenge)
|
||||
authUrl.searchParams.set('code_challenge_method', 'S256')
|
||||
|
||||
logger.info('Initiating Snowflake OAuth flow (CONFIDENTIAL client with PKCE)', {
|
||||
logger.info('Initiating Snowflake OAuth flow with user-provided credentials (PKCE)', {
|
||||
userId: session.user.id,
|
||||
accountUrl: cleanAccountUrl,
|
||||
authUrl: authUrl.toString(),
|
||||
redirectUri,
|
||||
clientId,
|
||||
hasClientId: !!clientId,
|
||||
hasClientSecret: !!clientSecret,
|
||||
redirectUri,
|
||||
hasPkce: true,
|
||||
parametersCount: authUrl.searchParams.toString().length,
|
||||
})
|
||||
|
||||
logger.info('Authorization URL parameters:', {
|
||||
client_id: authUrl.searchParams.get('client_id'),
|
||||
response_type: authUrl.searchParams.get('response_type'),
|
||||
redirect_uri: authUrl.searchParams.get('redirect_uri'),
|
||||
state_length: authUrl.searchParams.get('state')?.length,
|
||||
scope: authUrl.searchParams.get('scope'),
|
||||
has_pkce: authUrl.searchParams.has('code_challenge'),
|
||||
code_challenge_method: authUrl.searchParams.get('code_challenge_method'),
|
||||
return NextResponse.json({
|
||||
authUrl: authUrl.toString(),
|
||||
})
|
||||
|
||||
return NextResponse.redirect(authUrl.toString())
|
||||
} catch (error) {
|
||||
logger.error('Error initiating Snowflake authorization:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getBaseUrl } from '@/lib/urls/utils'
|
||||
import { db } from '@/../../packages/db'
|
||||
@@ -41,10 +40,12 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.redirect(`${getBaseUrl()}/workspace?error=snowflake_invalid_callback`)
|
||||
}
|
||||
|
||||
// Decode state to get account URL and code verifier
|
||||
// Decode state to get account URL, credentials, and code verifier
|
||||
let stateData: {
|
||||
userId: string
|
||||
accountUrl: string
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
timestamp: number
|
||||
codeVerifier: string
|
||||
}
|
||||
@@ -54,6 +55,8 @@ export async function GET(request: NextRequest) {
|
||||
logger.info('Decoded state successfully', {
|
||||
userId: stateData.userId,
|
||||
accountUrl: stateData.accountUrl,
|
||||
hasClientId: !!stateData.clientId,
|
||||
hasClientSecret: !!stateData.clientSecret,
|
||||
age: Date.now() - stateData.timestamp,
|
||||
hasCodeVerifier: !!stateData.codeVerifier,
|
||||
})
|
||||
@@ -79,12 +82,13 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.redirect(`${getBaseUrl()}/workspace?error=snowflake_state_expired`)
|
||||
}
|
||||
|
||||
const clientId = env.SNOWFLAKE_CLIENT_ID
|
||||
const clientSecret = env.SNOWFLAKE_CLIENT_SECRET
|
||||
// Use user-provided credentials from state
|
||||
const clientId = stateData.clientId
|
||||
const clientSecret = stateData.clientSecret
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
logger.error('Snowflake OAuth credentials not configured')
|
||||
return NextResponse.redirect(`${getBaseUrl()}/workspace?error=snowflake_not_configured`)
|
||||
logger.error('Missing client credentials in state')
|
||||
return NextResponse.redirect(`${getBaseUrl()}/workspace?error=snowflake_missing_credentials`)
|
||||
}
|
||||
|
||||
// Exchange authorization code for tokens
|
||||
@@ -165,12 +169,22 @@ export async function GET(request: NextRequest) {
|
||||
? new Date(now.getTime() + tokens.expires_in * 1000)
|
||||
: new Date(now.getTime() + 10 * 60 * 1000) // Default 10 minutes
|
||||
|
||||
// Store user-provided OAuth credentials securely
|
||||
// We use the password field to store a JSON object with clientId and clientSecret
|
||||
// and idToken to store the accountUrl for easier retrieval
|
||||
const oauthCredentials = JSON.stringify({
|
||||
clientId: stateData.clientId,
|
||||
clientSecret: stateData.clientSecret,
|
||||
})
|
||||
|
||||
const accountData = {
|
||||
userId: session.user.id,
|
||||
providerId: 'snowflake',
|
||||
accountId: stateData.accountUrl, // Store the Snowflake account URL here
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || null,
|
||||
idToken: stateData.accountUrl, // Store accountUrl for easier access
|
||||
password: oauthCredentials, // Store clientId and clientSecret as JSON
|
||||
accessTokenExpiresAt: expiresAt,
|
||||
scope: tokens.scope || null,
|
||||
updatedAt: now,
|
||||
|
||||
@@ -273,7 +273,11 @@ export function OAuthRequiredModal({
|
||||
newScopes = [],
|
||||
}: OAuthRequiredModalProps) {
|
||||
const [snowflakeAccountUrl, setSnowflakeAccountUrl] = useState('')
|
||||
const [snowflakeClientId, setSnowflakeClientId] = useState('')
|
||||
const [snowflakeClientSecret, setSnowflakeClientSecret] = useState('')
|
||||
const [accountUrlError, setAccountUrlError] = useState('')
|
||||
const [clientIdError, setClientIdError] = useState('')
|
||||
const [clientSecretError, setClientSecretError] = useState('')
|
||||
|
||||
const effectiveServiceId = serviceId || getServiceIdFromScopes(provider, requiredScopes)
|
||||
const { baseProvider } = parseProvider(provider)
|
||||
@@ -305,20 +309,64 @@ export function OAuthRequiredModal({
|
||||
try {
|
||||
const providerId = getProviderIdFromServiceId(effectiveServiceId)
|
||||
|
||||
// Special handling for Snowflake - requires account URL
|
||||
// Special handling for Snowflake - requires account URL, client ID, and client secret
|
||||
if (providerId === 'snowflake') {
|
||||
let hasError = false
|
||||
|
||||
if (!snowflakeAccountUrl.trim()) {
|
||||
setAccountUrlError('Account URL is required')
|
||||
hasError = true
|
||||
}
|
||||
|
||||
if (!snowflakeClientId.trim()) {
|
||||
setClientIdError('Client ID is required')
|
||||
hasError = true
|
||||
}
|
||||
|
||||
if (!snowflakeClientSecret.trim()) {
|
||||
setClientSecretError('Client Secret is required')
|
||||
hasError = true
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
return
|
||||
}
|
||||
|
||||
onClose()
|
||||
|
||||
logger.info('Initiating Snowflake OAuth:', {
|
||||
logger.info('Initiating Snowflake OAuth with user credentials:', {
|
||||
accountUrl: snowflakeAccountUrl,
|
||||
hasClientId: !!snowflakeClientId,
|
||||
hasClientSecret: !!snowflakeClientSecret,
|
||||
})
|
||||
|
||||
window.location.href = `/api/auth/snowflake/authorize?accountUrl=${encodeURIComponent(snowflakeAccountUrl)}`
|
||||
// Call the authorize endpoint with user credentials
|
||||
try {
|
||||
const response = await fetch('/api/auth/snowflake/authorize', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
accountUrl: snowflakeAccountUrl,
|
||||
clientId: snowflakeClientId,
|
||||
clientSecret: snowflakeClientSecret,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to initiate Snowflake OAuth')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Redirect to Snowflake authorization page
|
||||
window.location.href = data.authUrl
|
||||
} catch (error) {
|
||||
logger.error('Error initiating Snowflake OAuth:', error)
|
||||
// TODO: Show error to user
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -352,7 +400,7 @@ export function OAuthRequiredModal({
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<ModalContent className='w-[460px]'>
|
||||
<ModalContent className='w-[460px]'>
|
||||
<ModalHeader>Connect {providerName}</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='flex flex-col gap-[16px]'>
|
||||
@@ -414,4 +462,4 @@ export function OAuthRequiredModal({
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1529,28 +1529,43 @@ function buildAuthRequest(
|
||||
* 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 metadata Optional metadata (e.g., accountUrl for Snowflake)
|
||||
* @param metadata Optional metadata (e.g., accountUrl, clientId, clientSecret for Snowflake)
|
||||
* @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,
|
||||
metadata?: { accountUrl?: string }
|
||||
metadata?: { accountUrl?: string; clientId?: string; clientSecret?: string }
|
||||
): Promise<{ accessToken: string; expiresIn: number; refreshToken: string } | null> {
|
||||
try {
|
||||
// Get the provider from the providerId (e.g., 'google-drive' -> 'google')
|
||||
const provider = providerId.split('-')[0]
|
||||
|
||||
// Get provider configuration
|
||||
const config = getProviderAuthConfig(provider)
|
||||
let config: ProviderAuthConfig
|
||||
|
||||
// For Snowflake, use the account-specific token endpoint
|
||||
let tokenEndpoint = config.tokenEndpoint
|
||||
if (provider === 'snowflake' && metadata?.accountUrl) {
|
||||
tokenEndpoint = `https://${metadata.accountUrl}/oauth/token-request`
|
||||
logger.info('Using Snowflake account-specific token endpoint', {
|
||||
if (provider === 'snowflake' && metadata?.clientId && metadata?.clientSecret) {
|
||||
config = {
|
||||
tokenEndpoint: `https://${metadata.accountUrl}/oauth/token-request`,
|
||||
clientId: metadata.clientId,
|
||||
clientSecret: metadata.clientSecret,
|
||||
useBasicAuth: false,
|
||||
supportsRefreshTokenRotation: true,
|
||||
}
|
||||
logger.info('Using user-provided Snowflake OAuth credentials for token refresh', {
|
||||
accountUrl: metadata.accountUrl,
|
||||
hasClientId: !!metadata.clientId,
|
||||
hasClientSecret: !!metadata.clientSecret,
|
||||
})
|
||||
} else {
|
||||
config = getProviderAuthConfig(provider)
|
||||
|
||||
// For Snowflake without user credentials, use the account-specific token endpoint
|
||||
if (provider === 'snowflake' && metadata?.accountUrl) {
|
||||
config.tokenEndpoint = `https://${metadata.accountUrl}/oauth/token-request`
|
||||
logger.info('Using Snowflake account-specific token endpoint', {
|
||||
accountUrl: metadata.accountUrl,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Build authentication request
|
||||
|
||||
Reference in New Issue
Block a user