client id and secret for individual projects, no more single client and secret

This commit is contained in:
aadamgough
2025-11-19 13:04:25 -08:00
parent e3dca6635a
commit 17a164508f
5 changed files with 159 additions and 57 deletions

View File

@@ -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(

View File

@@ -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 })

View File

@@ -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,

View File

@@ -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>
)
}
}

View File

@@ -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