diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index 08dd16fdff..9fe7d8510e 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -4,6 +4,11 @@ import { createLogger } from '@sim/logger' import { and, desc, eq, inArray } from 'drizzle-orm' import { getSession } from '@/lib/auth' import { refreshOAuthToken } from '@/lib/oauth' +import { + getMicrosoftRefreshTokenExpiry, + isMicrosoftProvider, + PROACTIVE_REFRESH_THRESHOLD_DAYS, +} from '@/lib/oauth/microsoft' const logger = createLogger('OAuthUtilsAPI') @@ -205,15 +210,32 @@ export async function refreshAccessTokenIfNeeded( } // Decide if we should refresh: token missing OR expired - const expiresAt = credential.accessTokenExpiresAt + const accessTokenExpiresAt = credential.accessTokenExpiresAt + const refreshTokenExpiresAt = credential.refreshTokenExpiresAt const now = new Date() - const shouldRefresh = - !!credential.refreshToken && (!credential.accessToken || (expiresAt && expiresAt <= now)) + + // Check if access token needs refresh (missing or expired) + const accessTokenNeedsRefresh = + !!credential.refreshToken && + (!credential.accessToken || (accessTokenExpiresAt && accessTokenExpiresAt <= now)) + + // Check if we should proactively refresh to prevent refresh token expiry + // This applies to Microsoft providers whose refresh tokens expire after 90 days of inactivity + const proactiveRefreshThreshold = new Date( + now.getTime() + PROACTIVE_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000 + ) + const refreshTokenNeedsProactiveRefresh = + !!credential.refreshToken && + isMicrosoftProvider(credential.providerId) && + refreshTokenExpiresAt && + refreshTokenExpiresAt <= proactiveRefreshThreshold + + const shouldRefresh = accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh const accessToken = credential.accessToken if (shouldRefresh) { - logger.info(`[${requestId}] Token expired, attempting to refresh for credential`) + logger.info(`[${requestId}] Refreshing token for credential`) try { const refreshedToken = await refreshOAuthToken( credential.providerId, @@ -227,11 +249,15 @@ export async function refreshAccessTokenIfNeeded( userId: credential.userId, hasRefreshToken: !!credential.refreshToken, }) + if (!accessTokenNeedsRefresh && accessToken) { + logger.info(`[${requestId}] Proactive refresh failed but access token still valid`) + return accessToken + } return null } // Prepare update data - const updateData: any = { + const updateData: Record = { accessToken: refreshedToken.accessToken, accessTokenExpiresAt: new Date(Date.now() + refreshedToken.expiresIn * 1000), updatedAt: new Date(), @@ -243,6 +269,10 @@ export async function refreshAccessTokenIfNeeded( updateData.refreshToken = refreshedToken.refreshToken } + if (isMicrosoftProvider(credential.providerId)) { + updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry() + } + // Update the token in the database await db.update(account).set(updateData).where(eq(account.id, credentialId)) @@ -256,6 +286,10 @@ export async function refreshAccessTokenIfNeeded( credentialId, userId: credential.userId, }) + if (!accessTokenNeedsRefresh && accessToken) { + logger.info(`[${requestId}] Proactive refresh failed but access token still valid`) + return accessToken + } return null } } else if (!accessToken) { @@ -277,10 +311,27 @@ export async function refreshTokenIfNeeded( credentialId: string ): Promise<{ accessToken: string; refreshed: boolean }> { // Decide if we should refresh: token missing OR expired - const expiresAt = credential.accessTokenExpiresAt + const accessTokenExpiresAt = credential.accessTokenExpiresAt + const refreshTokenExpiresAt = credential.refreshTokenExpiresAt const now = new Date() - const shouldRefresh = - !!credential.refreshToken && (!credential.accessToken || (expiresAt && expiresAt <= now)) + + // Check if access token needs refresh (missing or expired) + const accessTokenNeedsRefresh = + !!credential.refreshToken && + (!credential.accessToken || (accessTokenExpiresAt && accessTokenExpiresAt <= now)) + + // Check if we should proactively refresh to prevent refresh token expiry + // This applies to Microsoft providers whose refresh tokens expire after 90 days of inactivity + const proactiveRefreshThreshold = new Date( + now.getTime() + PROACTIVE_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000 + ) + const refreshTokenNeedsProactiveRefresh = + !!credential.refreshToken && + isMicrosoftProvider(credential.providerId) && + refreshTokenExpiresAt && + refreshTokenExpiresAt <= proactiveRefreshThreshold + + const shouldRefresh = accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh // If token appears valid and present, return it directly if (!shouldRefresh) { @@ -293,13 +344,17 @@ export async function refreshTokenIfNeeded( if (!refreshResult) { logger.error(`[${requestId}] Failed to refresh token for credential`) + if (!accessTokenNeedsRefresh && credential.accessToken) { + logger.info(`[${requestId}] Proactive refresh failed but access token still valid`) + return { accessToken: credential.accessToken, refreshed: false } + } throw new Error('Failed to refresh token') } const { accessToken: refreshedToken, expiresIn, refreshToken: newRefreshToken } = refreshResult // Prepare update data - const updateData: any = { + const updateData: Record = { accessToken: refreshedToken, accessTokenExpiresAt: new Date(Date.now() + expiresIn * 1000), // Use provider's expiry updatedAt: new Date(), @@ -311,6 +366,10 @@ export async function refreshTokenIfNeeded( updateData.refreshToken = newRefreshToken } + if (isMicrosoftProvider(credential.providerId)) { + updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry() + } + await db.update(account).set(updateData).where(eq(account.id, credentialId)) logger.info(`[${requestId}] Successfully refreshed access token`) @@ -331,6 +390,11 @@ export async function refreshTokenIfNeeded( } } + if (!accessTokenNeedsRefresh && credential.accessToken) { + logger.info(`[${requestId}] Proactive refresh failed but access token still valid`) + return { accessToken: credential.accessToken, refreshed: false } + } + logger.error(`[${requestId}] Refresh failed and no valid token found in DB`, error) throw error } diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 0ff169a2fd..518ecf930b 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -64,6 +64,8 @@ import { SSO_TRUSTED_PROVIDERS } from './sso/constants' const logger = createLogger('Auth') +import { getMicrosoftRefreshTokenExpiry, isMicrosoftProvider } from '@/lib/oauth/microsoft' + const validStripeKey = env.STRIPE_SECRET_KEY let stripeClient = null @@ -187,6 +189,10 @@ export const auth = betterAuth({ } } + const refreshTokenExpiresAt = isMicrosoftProvider(account.providerId) + ? getMicrosoftRefreshTokenExpiry() + : account.refreshTokenExpiresAt + await db .update(schema.account) .set({ @@ -195,7 +201,7 @@ export const auth = betterAuth({ refreshToken: account.refreshToken, idToken: account.idToken, accessTokenExpiresAt: account.accessTokenExpiresAt, - refreshTokenExpiresAt: account.refreshTokenExpiresAt, + refreshTokenExpiresAt, scope: scopeToStore, updatedAt: new Date(), }) @@ -292,6 +298,13 @@ export const auth = betterAuth({ } } + if (isMicrosoftProvider(account.providerId)) { + await db + .update(schema.account) + .set({ refreshTokenExpiresAt: getMicrosoftRefreshTokenExpiry() }) + .where(eq(schema.account.id, account.id)) + } + // Sync webhooks for credential sets after connecting a new credential const requestId = crypto.randomUUID().slice(0, 8) const userMemberships = await db diff --git a/apps/sim/lib/oauth/index.ts b/apps/sim/lib/oauth/index.ts index a8f9ce5859..f4c641730e 100644 --- a/apps/sim/lib/oauth/index.ts +++ b/apps/sim/lib/oauth/index.ts @@ -1,3 +1,4 @@ +export * from './microsoft' export * from './oauth' export * from './types' export * from './utils' diff --git a/apps/sim/lib/oauth/microsoft.ts b/apps/sim/lib/oauth/microsoft.ts new file mode 100644 index 0000000000..f512ee1e63 --- /dev/null +++ b/apps/sim/lib/oauth/microsoft.ts @@ -0,0 +1,19 @@ +export const MICROSOFT_REFRESH_TOKEN_LIFETIME_DAYS = 90 +export const PROACTIVE_REFRESH_THRESHOLD_DAYS = 7 + +export const MICROSOFT_PROVIDERS = new Set([ + 'microsoft-excel', + 'microsoft-planner', + 'microsoft-teams', + 'outlook', + 'onedrive', + 'sharepoint', +]) + +export function isMicrosoftProvider(providerId: string): boolean { + return MICROSOFT_PROVIDERS.has(providerId) +} + +export function getMicrosoftRefreshTokenExpiry(): Date { + return new Date(Date.now() + MICROSOFT_REFRESH_TOKEN_LIFETIME_DAYS * 24 * 60 * 60 * 1000) +} diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 2f812b13f4..c8cff3dde5 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -835,6 +835,7 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig { clientId, clientSecret, useBasicAuth: true, + supportsRefreshTokenRotation: true, } } case 'confluence': { @@ -883,6 +884,7 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig { clientId, clientSecret, useBasicAuth: false, + supportsRefreshTokenRotation: true, } } case 'microsoft': @@ -910,6 +912,7 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig { clientId, clientSecret, useBasicAuth: true, + supportsRefreshTokenRotation: true, } } case 'dropbox': {