From 1af5dcbb9603757e494354493aaaca16870f6725 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 8 Apr 2025 01:11:16 -0700 Subject: [PATCH] fix(oauth): fix airtable oauth token refresh --- sim/app/api/auth/oauth/token/route.ts | 56 +++++++++++++++-------- sim/app/api/auth/oauth/utils.ts | 46 ++++++++++++------- sim/lib/oauth.ts | 66 +++++++++++++++++++++------ 3 files changed, 118 insertions(+), 50 deletions(-) diff --git a/sim/app/api/auth/oauth/token/route.ts b/sim/app/api/auth/oauth/token/route.ts index 59a198c59c..dabe5c7dca 100644 --- a/sim/app/api/auth/oauth/token/route.ts +++ b/sim/app/api/auth/oauth/token/route.ts @@ -87,16 +87,26 @@ export async function POST(request: NextRequest) { throw new Error('Failed to refresh token') } - const { accessToken: refreshedToken, expiresIn } = refreshResult + const { + accessToken: refreshedToken, + expiresIn, + refreshToken: newRefreshToken, + } = refreshResult - await db - .update(account) - .set({ - accessToken: refreshedToken, - accessTokenExpiresAt: new Date(Date.now() + expiresIn * 1000), // Use provider's expiry - updatedAt: new Date(), - }) - .where(eq(account.id, credentialId)) + // Prepare update data + const updateData: any = { + accessToken: refreshedToken, + accessTokenExpiresAt: new Date(Date.now() + expiresIn * 1000), // Use provider's expiry + updatedAt: new Date(), + } + + // If we received a new refresh token, update it + if (newRefreshToken && newRefreshToken !== credential.refreshToken) { + logger.info(`[${requestId}] Updating refresh token for credential: ${credentialId}`) + updateData.refreshToken = newRefreshToken + } + + await db.update(account).set(updateData).where(eq(account.id, credentialId)) logger.info(`[${requestId}] Successfully refreshed access token`) return NextResponse.json({ accessToken: refreshedToken }, { status: 200 }) @@ -180,18 +190,28 @@ export async function GET(request: NextRequest) { throw new Error('Failed to refresh token') } - const { accessToken: refreshedToken, expiresIn } = refreshResult + const { + accessToken: refreshedToken, + expiresIn, + refreshToken: newRefreshToken, + } = refreshResult logger.info(`[${requestId}] Token refreshed successfully`) + // Prepare update data + const updateData: any = { + accessToken: refreshedToken, + accessTokenExpiresAt: new Date(Date.now() + expiresIn * 1000), // Use provider's expiry + updatedAt: new Date(), + } + + // If we received a new refresh token, update it + if (newRefreshToken && newRefreshToken !== credential.refreshToken) { + logger.info(`[${requestId}] Updating refresh token for credential: ${credentialId}`) + updateData.refreshToken = newRefreshToken + } + // Update the token in the database with the correct expiration time - await db - .update(account) - .set({ - accessToken: refreshedToken, - accessTokenExpiresAt: new Date(Date.now() + expiresIn * 1000), // Use provider's expiry - updatedAt: new Date(), - }) - .where(eq(account.id, credentialId)) + await db.update(account).set(updateData).where(eq(account.id, credentialId)) accessToken = refreshedToken } catch (refreshError) { diff --git a/sim/app/api/auth/oauth/utils.ts b/sim/app/api/auth/oauth/utils.ts index 73086cfd2f..5193098ce0 100644 --- a/sim/app/api/auth/oauth/utils.ts +++ b/sim/app/api/auth/oauth/utils.ts @@ -55,17 +55,23 @@ export async function getOAuthToken(userId: string, providerId: string): Promise return null } - const { accessToken, expiresIn } = refreshResult + const { accessToken, expiresIn, refreshToken: newRefreshToken } = refreshResult + + // Update the database with new tokens + const updateData: any = { + accessToken, + accessTokenExpiresAt: new Date(Date.now() + expiresIn * 1000), // Convert seconds to milliseconds + updatedAt: new Date(), + } + + // If we received a new refresh token (some providers like Airtable rotate them), save it + if (newRefreshToken && newRefreshToken !== credential.refreshToken) { + logger.info(`Updating refresh token for user ${userId}, provider ${providerId}`) + updateData.refreshToken = newRefreshToken + } // Update the token in the database with the actual expiration time from the provider - await db - .update(account) - .set({ - accessToken, - accessTokenExpiresAt: new Date(Date.now() + expiresIn * 1000), // Convert seconds to milliseconds - updatedAt: new Date(), - }) - .where(eq(account.id, credential.id)) + await db.update(account).set(updateData).where(eq(account.id, credential.id)) logger.info(`Successfully refreshed token for user ${userId}, provider ${providerId}`) return accessToken @@ -137,15 +143,21 @@ export async function refreshAccessTokenIfNeeded( return null } + // Prepare update data + const updateData: any = { + accessToken: refreshedToken.accessToken, + accessTokenExpiresAt: new Date(Date.now() + refreshedToken.expiresIn * 1000), + updatedAt: new Date(), + } + + // If we received a new refresh token, update it + if (refreshedToken.refreshToken && refreshedToken.refreshToken !== credential.refreshToken) { + logger.info(`[${requestId || ''}] Updating refresh token for credential: ${credentialId}`) + updateData.refreshToken = refreshedToken.refreshToken + } + // Update the token in the database - await db - .update(account) - .set({ - accessToken: refreshedToken.accessToken, - accessTokenExpiresAt: new Date(Date.now() + refreshedToken.expiresIn * 1000), // Default 1 hour expiry - updatedAt: new Date(), - }) - .where(eq(account.id, credentialId)) + await db.update(account).set(updateData).where(eq(account.id, credentialId)) logger.info( `[${requestId || ''}] Successfully refreshed access token for credential: ${credentialId}` diff --git a/sim/lib/oauth.ts b/sim/lib/oauth.ts index 79179c5802..9f0eaa27c7 100644 --- a/sim/lib/oauth.ts +++ b/sim/lib/oauth.ts @@ -338,7 +338,7 @@ export function parseProvider(provider: OAuthProvider): ProviderConfig { export async function refreshOAuthToken( providerId: string, refreshToken: string -): Promise<{ accessToken: string; expiresIn: number } | null> { +): 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] @@ -397,25 +397,38 @@ export async function refreshOAuthToken( }), } - // For providers using Basic auth, add Authorization header - if (useBasicAuth) { - const basicAuth = Buffer.from(`${clientId}:${clientSecret}`).toString('base64') - headers['Authorization'] = `Basic ${basicAuth}` - } - // Prepare request body const bodyParams: Record = { grant_type: 'refresh_token', refresh_token: refreshToken, } - // For Airtable specifically, include client_id in body even with Basic auth + // For Airtable, check if we have both client ID and secret if (provider === 'airtable') { - bodyParams.client_id = clientId - } else if (!useBasicAuth) { - // For other non-Basic auth providers, include both credentials - bodyParams.client_id = clientId - bodyParams.client_secret = clientSecret + // Airtable requires Basic Auth with client ID and secret in the Authorization header + // Do not include client_id or client_secret in the body when using Basic Auth + if (clientId && clientSecret) { + const basicAuth = Buffer.from(`${clientId}:${clientSecret}`).toString('base64') + headers['Authorization'] = `Basic ${basicAuth}` + + // Make sure to include refresh_token in body params but not client_id/client_secret + // This ensures we're not sending credentials in both header and body + delete bodyParams.client_id + delete bodyParams.client_secret + } else { + throw new Error('Both client ID and client secret are required for Airtable OAuth') + } + } else { + // For other providers, use the general approach + if (useBasicAuth) { + const basicAuth = Buffer.from(`${clientId}:${clientSecret}`).toString('base64') + headers['Authorization'] = `Basic ${basicAuth}` + } + + if (!useBasicAuth) { + bodyParams.client_id = clientId + bodyParams.client_secret = clientSecret + } } // Refresh the token @@ -430,6 +443,12 @@ export async function refreshOAuthToken( logger.error('Token refresh failed:', { status: response.status, error: errorText, + provider, + headers: JSON.stringify(headers, null, 2).replace( + /"Authorization":"[^"]*"/, + '"Authorization":"[REDACTED]"' + ), + bodyParams: JSON.stringify(bodyParams), }) throw new Error(`Failed to refresh token: ${response.status} ${errorText}`) } @@ -439,6 +458,14 @@ export async function refreshOAuthToken( // Extract token and expiration (different providers may use different field names) const accessToken = data.access_token + // For Airtable, also capture the new refresh token if provided + // Airtable may rotate refresh tokens + let newRefreshToken = null + if (provider === 'airtable' && data.refresh_token) { + newRefreshToken = data.refresh_token + logger.info('Received new refresh token from Airtable') + } + // Get expiration time - use provider's value or default to 1 hour (3600 seconds) // Different providers use different names for this field const expiresIn = data.expires_in || data.expiresIn || 3600 @@ -448,8 +475,17 @@ export async function refreshOAuthToken( return null } - logger.info('Token refreshed successfully with expiration', { expiresIn }) - return { accessToken, expiresIn } + logger.info('Token refreshed successfully with expiration', { + expiresIn, + hasNewRefreshToken: !!newRefreshToken, + provider, + }) + + return { + accessToken, + expiresIn, + refreshToken: newRefreshToken || refreshToken, // Return new refresh token if available + } } catch (error) { logger.error('Error refreshing token:', { error }) return null