fix(oauth): fix airtable oauth token refresh

This commit is contained in:
Waleed Latif
2025-04-08 01:11:16 -07:00
parent 358434fb74
commit 1af5dcbb96
3 changed files with 118 additions and 50 deletions

View File

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

View File

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

View File

@@ -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<string, string> = {
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