From 7fe7a85212972646fda095d64f59e1e302a1629e Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 5 Feb 2026 13:26:26 -0800 Subject: [PATCH] added custom oauth for tiktok --- apps/sim/app/api/auth/oauth/utils.ts | 16 ++- .../app/api/auth/tiktok/authorize/route.ts | 70 ++++++++++ .../sim/app/api/auth/tiktok/callback/route.ts | 130 ++++++++++++++++++ apps/sim/app/api/auth/tiktok/store/route.ts | 108 +++++++++++++++ .../components/oauth-required-modal.tsx | 14 ++ apps/sim/lib/auth/auth.ts | 86 +++--------- apps/sim/lib/oauth/index.ts | 1 - apps/sim/lib/oauth/microsoft.ts | 19 --- apps/sim/lib/oauth/oauth.ts | 10 +- apps/sim/lib/oauth/utils.ts | 43 ++++++ 10 files changed, 407 insertions(+), 90 deletions(-) create mode 100644 apps/sim/app/api/auth/tiktok/authorize/route.ts create mode 100644 apps/sim/app/api/auth/tiktok/callback/route.ts create mode 100644 apps/sim/app/api/auth/tiktok/store/route.ts delete mode 100644 apps/sim/lib/oauth/microsoft.ts diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index 9fe7d8510..25a765b82 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -6,9 +6,11 @@ import { getSession } from '@/lib/auth' import { refreshOAuthToken } from '@/lib/oauth' import { getMicrosoftRefreshTokenExpiry, + getTikTokRefreshTokenExpiry, isMicrosoftProvider, + isTikTokProvider, PROACTIVE_REFRESH_THRESHOLD_DAYS, -} from '@/lib/oauth/microsoft' +} from '@/lib/oauth/utils' const logger = createLogger('OAuthUtilsAPI') @@ -220,13 +222,13 @@ export async function refreshAccessTokenIfNeeded( (!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 + // This applies to providers with expiring refresh tokens (Microsoft: 90 days, TikTok: 365 days) const proactiveRefreshThreshold = new Date( now.getTime() + PROACTIVE_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000 ) const refreshTokenNeedsProactiveRefresh = !!credential.refreshToken && - isMicrosoftProvider(credential.providerId) && + (isMicrosoftProvider(credential.providerId) || isTikTokProvider(credential.providerId)) && refreshTokenExpiresAt && refreshTokenExpiresAt <= proactiveRefreshThreshold @@ -271,6 +273,8 @@ export async function refreshAccessTokenIfNeeded( if (isMicrosoftProvider(credential.providerId)) { updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry() + } else if (isTikTokProvider(credential.providerId)) { + updateData.refreshTokenExpiresAt = getTikTokRefreshTokenExpiry() } // Update the token in the database @@ -321,13 +325,13 @@ export async function refreshTokenIfNeeded( (!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 + // This applies to providers with expiring refresh tokens (Microsoft: 90 days, TikTok: 365 days) const proactiveRefreshThreshold = new Date( now.getTime() + PROACTIVE_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000 ) const refreshTokenNeedsProactiveRefresh = !!credential.refreshToken && - isMicrosoftProvider(credential.providerId) && + (isMicrosoftProvider(credential.providerId) || isTikTokProvider(credential.providerId)) && refreshTokenExpiresAt && refreshTokenExpiresAt <= proactiveRefreshThreshold @@ -368,6 +372,8 @@ export async function refreshTokenIfNeeded( if (isMicrosoftProvider(credential.providerId)) { updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry() + } else if (isTikTokProvider(credential.providerId)) { + updateData.refreshTokenExpiresAt = getTikTokRefreshTokenExpiry() } await db.update(account).set(updateData).where(eq(account.id, credentialId)) diff --git a/apps/sim/app/api/auth/tiktok/authorize/route.ts b/apps/sim/app/api/auth/tiktok/authorize/route.ts new file mode 100644 index 000000000..95a37cb56 --- /dev/null +++ b/apps/sim/app/api/auth/tiktok/authorize/route.ts @@ -0,0 +1,70 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { env } from '@/lib/core/config/env' +import { getBaseUrl } from '@/lib/core/utils/urls' + +const logger = createLogger('TikTokAuthorize') + +export const dynamic = 'force-dynamic' + +export async function GET(request: NextRequest) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const clientKey = env.TIKTOK_CLIENT_ID + + if (!clientKey) { + logger.error('TIKTOK_CLIENT_ID not configured') + return NextResponse.json({ error: 'TikTok client key not configured' }, { status: 500 }) + } + + // Get the return URL from query params or use default + const searchParams = request.nextUrl.searchParams + const returnUrl = searchParams.get('returnUrl') || `${getBaseUrl()}/workspace` + + const baseUrl = getBaseUrl() + const redirectUri = `${baseUrl}/api/auth/tiktok/callback` + + // Generate a random state for CSRF protection + const state = Buffer.from( + JSON.stringify({ + returnUrl, + timestamp: Date.now(), + }) + ).toString('base64url') + + // TikTok scopes + const scopes = [ + 'user.info.basic', + 'user.info.profile', + 'user.info.stats', + 'video.list', + 'video.publish', + ] + + // Build TikTok authorization URL with client_key (not client_id) + // Note: TikTok expects raw commas in scope parameter, not URL-encoded %2C + // So we manually construct the URL to avoid automatic encoding + const scopeString = scopes.join(',') + const encodedRedirectUri = encodeURIComponent(redirectUri) + const encodedState = encodeURIComponent(state) + + const authUrl = `https://www.tiktok.com/v2/auth/authorize/?client_key=${clientKey}&response_type=code&scope=${scopeString}&redirect_uri=${encodedRedirectUri}&state=${encodedState}` + + logger.info('Redirecting to TikTok authorization', { + clientKey: clientKey ? `${clientKey.substring(0, 8)}...` : 'NOT SET', + redirectUri, + scopes: scopeString, + fullUrl: authUrl, + }) + + return NextResponse.redirect(authUrl) + } catch (error) { + logger.error('Error initiating TikTok authorization:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/auth/tiktok/callback/route.ts b/apps/sim/app/api/auth/tiktok/callback/route.ts new file mode 100644 index 000000000..0ae2c171f --- /dev/null +++ b/apps/sim/app/api/auth/tiktok/callback/route.ts @@ -0,0 +1,130 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { env } from '@/lib/core/config/env' +import { getBaseUrl } from '@/lib/core/utils/urls' + +const logger = createLogger('TikTokCallback') + +export const dynamic = 'force-dynamic' + +export async function GET(request: NextRequest) { + const baseUrl = getBaseUrl() + + try { + const session = await getSession() + if (!session?.user?.id) { + logger.error('No session found during TikTok callback') + return NextResponse.redirect(`${baseUrl}/workspace?error=unauthorized`) + } + + const searchParams = request.nextUrl.searchParams + const code = searchParams.get('code') + const state = searchParams.get('state') + const error = searchParams.get('error') + const errorDescription = searchParams.get('error_description') + + // Handle errors from TikTok + if (error) { + logger.error('TikTok authorization error:', { error, errorDescription }) + return NextResponse.redirect( + `${baseUrl}/workspace?error=tiktok_auth_failed&message=${encodeURIComponent(errorDescription || error)}` + ) + } + + if (!code) { + logger.error('No authorization code received from TikTok') + return NextResponse.redirect(`${baseUrl}/workspace?error=no_code`) + } + + // Parse state to get return URL + let returnUrl = `${baseUrl}/workspace` + if (state) { + try { + const stateData = JSON.parse(Buffer.from(state, 'base64url').toString()) + returnUrl = stateData.returnUrl || returnUrl + } catch { + logger.warn('Failed to parse state parameter') + } + } + + const clientKey = env.TIKTOK_CLIENT_ID + const clientSecret = env.TIKTOK_CLIENT_SECRET + + if (!clientKey || !clientSecret) { + logger.error('TikTok credentials not configured') + return NextResponse.redirect(`${baseUrl}/workspace?error=config_error`) + } + + const redirectUri = `${baseUrl}/api/auth/tiktok/callback` + + // Exchange authorization code for access token + // TikTok uses client_key instead of client_id + const tokenResponse = await fetch('https://open.tiktokapis.com/v2/oauth/token/', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_key: clientKey, + client_secret: clientSecret, + code, + grant_type: 'authorization_code', + redirect_uri: redirectUri, + }).toString(), + }) + + if (!tokenResponse.ok) { + const errorText = await tokenResponse.text() + logger.error('Failed to exchange code for token:', { + status: tokenResponse.status, + error: errorText, + }) + return NextResponse.redirect(`${baseUrl}/workspace?error=token_exchange_failed`) + } + + const tokenData = await tokenResponse.json() + + if (tokenData.error) { + logger.error('TikTok token error:', tokenData) + return NextResponse.redirect( + `${baseUrl}/workspace?error=tiktok_token_error&message=${encodeURIComponent(tokenData.error_description || tokenData.error)}` + ) + } + + const { access_token, refresh_token, expires_in, open_id, scope } = tokenData + + if (!access_token) { + logger.error('No access token in TikTok response:', tokenData) + return NextResponse.redirect(`${baseUrl}/workspace?error=no_access_token`) + } + + // Store the tokens by calling the store endpoint + const storeResponse = await fetch(`${baseUrl}/api/auth/tiktok/store`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: request.headers.get('cookie') || '', + }, + body: JSON.stringify({ + accessToken: access_token, + refreshToken: refresh_token, + expiresIn: expires_in, + openId: open_id, + scope, + }), + }) + + if (!storeResponse.ok) { + const storeError = await storeResponse.text() + logger.error('Failed to store TikTok tokens:', storeError) + return NextResponse.redirect(`${baseUrl}/workspace?error=store_failed`) + } + + logger.info('TikTok authorization successful') + return NextResponse.redirect(`${returnUrl}?tiktok_connected=true`) + } catch (error) { + logger.error('Error in TikTok callback:', error) + return NextResponse.redirect(`${baseUrl}/workspace?error=callback_error`) + } +} diff --git a/apps/sim/app/api/auth/tiktok/store/route.ts b/apps/sim/app/api/auth/tiktok/store/route.ts new file mode 100644 index 000000000..e64583b61 --- /dev/null +++ b/apps/sim/app/api/auth/tiktok/store/route.ts @@ -0,0 +1,108 @@ +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { getTikTokRefreshTokenExpiry } from '@/lib/oauth/utils' +import { safeAccountInsert } from '@/app/api/auth/oauth/utils' +import { db } from '@/../../packages/db' +import { account } from '@/../../packages/db/schema' + +const logger = createLogger('TikTokStore') + +export const dynamic = 'force-dynamic' + +export async function POST(request: NextRequest) { + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn('Unauthorized attempt to store TikTok token') + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const { accessToken, refreshToken, expiresIn, openId, scope } = body + + if (!accessToken || !openId) { + return NextResponse.json( + { success: false, error: 'Access token and open_id required' }, + { status: 400 } + ) + } + + // Fetch user info from TikTok to get display name + let displayName = 'TikTok User' + let avatarUrl: string | undefined + + try { + const userResponse = await fetch( + 'https://open.tiktokapis.com/v2/user/info/?fields=open_id,union_id,avatar_url,display_name', + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ) + + if (userResponse.ok) { + const userData = await userResponse.json() + if (userData.data?.user) { + displayName = userData.data.user.display_name || displayName + avatarUrl = userData.data.user.avatar_url + } + } + } catch (error) { + logger.warn('Failed to fetch TikTok user info:', error) + } + + const existing = await db.query.account.findFirst({ + where: and(eq(account.userId, session.user.id), eq(account.providerId, 'tiktok')), + }) + + const now = new Date() + const accessTokenExpiresAt = expiresIn ? new Date(Date.now() + expiresIn * 1000) : undefined + const refreshTokenExpiresAt = getTikTokRefreshTokenExpiry() + + if (existing) { + await db + .update(account) + .set({ + accessToken, + refreshToken, + accountId: openId, + scope: + scope || 'user.info.basic,user.info.profile,user.info.stats,video.list,video.publish', + accessTokenExpiresAt, + refreshTokenExpiresAt, + updatedAt: now, + }) + .where(eq(account.id, existing.id)) + + logger.info('Updated existing TikTok account', { accountId: openId }) + } else { + await safeAccountInsert( + { + id: `tiktok_${session.user.id}_${Date.now()}`, + userId: session.user.id, + providerId: 'tiktok', + accountId: openId, + accessToken, + refreshToken, + scope: + scope || 'user.info.basic,user.info.profile,user.info.stats,video.list,video.publish', + accessTokenExpiresAt, + refreshTokenExpiresAt, + createdAt: now, + updatedAt: now, + }, + { provider: 'TikTok', identifier: openId } + ) + + logger.info('Created new TikTok account', { accountId: openId }) + } + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Error storing TikTok token:', error) + return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx index b6e7aa4cb..a11618eb8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx @@ -294,6 +294,13 @@ const SCOPE_DESCRIPTIONS: Record = { 'user-follow-modify': 'Follow and unfollow artists and users', 'user-read-playback-position': 'View playback position in podcasts', 'ugc-image-upload': 'Upload images to Spotify playlists', + // TikTok scopes + 'user.info.basic': 'View basic profile info (avatar, display name)', + 'user.info.profile': 'View profile details (bio, verified status)', + 'user.info.stats': 'View account statistics (likes, followers, video count)', + 'video.list': 'View public videos', + 'video.publish': 'Post content to profile', + 'video.upload': 'Upload content as draft', } function getScopeDescription(scope: string): string { @@ -373,6 +380,13 @@ export function OAuthRequiredModal({ return } + if (providerId === 'tiktok') { + onClose() + const returnUrl = encodeURIComponent(window.location.href) + window.location.href = `/api/auth/tiktok/authorize?returnUrl=${returnUrl}` + return + } + await client.oauth2.link({ providerId, callbackURL: window.location.href, diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 937df10e3..3be51e7a2 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -23,6 +23,8 @@ import { renderPasswordResetEmail, renderWelcomeEmail, } from '@/components/emails' +import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous' +import { SSO_TRUSTED_PROVIDERS } from '@/lib/auth/sso/constants' import { sendPlanWelcomeEmail } from '@/lib/billing' import { authorizeSubscriptionReference } from '@/lib/billing/authorization' import { handleNewUser } from '@/lib/billing/core/usage' @@ -59,12 +61,15 @@ import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils' import { quickValidateEmail } from '@/lib/messaging/email/validation' import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' -import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous' -import { SSO_TRUSTED_PROVIDERS } from './sso/constants' const logger = createLogger('Auth') -import { getMicrosoftRefreshTokenExpiry, isMicrosoftProvider } from '@/lib/oauth/microsoft' +import { + getMicrosoftRefreshTokenExpiry, + getTikTokRefreshTokenExpiry, + isMicrosoftProvider, + isTikTokProvider, +} from '@/lib/oauth/utils' const validStripeKey = env.STRIPE_SECRET_KEY @@ -191,7 +196,9 @@ export const auth = betterAuth({ const refreshTokenExpiresAt = isMicrosoftProvider(account.providerId) ? getMicrosoftRefreshTokenExpiry() - : account.refreshTokenExpiresAt + : isTikTokProvider(account.providerId) + ? getTikTokRefreshTokenExpiry() + : account.refreshTokenExpiresAt await db .update(schema.account) @@ -316,6 +323,13 @@ export const auth = betterAuth({ .where(eq(schema.account.id, account.id)) } + if (isTikTokProvider(account.providerId)) { + await db + .update(schema.account) + .set({ refreshTokenExpiresAt: getTikTokRefreshTokenExpiry() }) + .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 @@ -2495,66 +2509,10 @@ export const auth = betterAuth({ }, }, - // TikTok provider - { - providerId: 'tiktok', - clientId: env.TIKTOK_CLIENT_ID as string, - clientSecret: env.TIKTOK_CLIENT_SECRET as string, - authorizationUrl: 'https://www.tiktok.com/v2/auth/authorize/', - tokenUrl: 'https://open.tiktokapis.com/v2/oauth/token/', - scopes: [ - 'user.info.basic', - 'user.info.profile', - 'user.info.stats', - 'video.list', - 'video.publish', - ], - responseType: 'code', - redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/tiktok`, - getUserInfo: async (tokens) => { - try { - logger.info('Fetching TikTok user profile') - - const response = await fetch( - 'https://open.tiktokapis.com/v2/user/info/?fields=open_id,union_id,avatar_url,display_name', - { - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - } - ) - - if (!response.ok) { - logger.error('Failed to fetch TikTok user info', { - status: response.status, - statusText: response.statusText, - }) - throw new Error('Failed to fetch user info') - } - - const data = await response.json() - const profile = data.data?.user - - if (!profile) { - logger.error('No user data in TikTok response') - return null - } - - return { - id: `${profile.open_id}-${crypto.randomUUID()}`, - name: profile.display_name || 'TikTok User', - email: `${profile.open_id}@tiktok.user`, - emailVerified: false, - image: profile.avatar_url || undefined, - createdAt: new Date(), - updatedAt: new Date(), - } - } catch (error) { - logger.error('Error in TikTok getUserInfo:', { error }) - return null - } - }, - }, + // TikTok provider - REMOVED from generic OAuth + // TikTok uses non-standard OAuth (client_key instead of client_id) + // and cannot work with the generic OAuth plugin. + // TikTok OAuth is handled via custom routes at /api/auth/tiktok/* // WordPress.com provider { diff --git a/apps/sim/lib/oauth/index.ts b/apps/sim/lib/oauth/index.ts index f4c641730..a8f9ce585 100644 --- a/apps/sim/lib/oauth/index.ts +++ b/apps/sim/lib/oauth/index.ts @@ -1,4 +1,3 @@ -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 deleted file mode 100644 index f512ee1e6..000000000 --- a/apps/sim/lib/oauth/microsoft.ts +++ /dev/null @@ -1,19 +0,0 @@ -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 f1d3419f4..e0efa9e07 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -832,6 +832,11 @@ interface ProviderAuthConfig { * instead of in the request body. Used by Cal.com. */ refreshTokenInAuthHeader?: boolean + /** + * Custom parameter name for client ID in request body. + * Defaults to 'client_id'. TikTok uses 'client_key'. + */ + clientIdParamName?: string } /** @@ -1168,6 +1173,7 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig { clientSecret, useBasicAuth: false, supportsRefreshTokenRotation: true, + clientIdParamName: 'client_key', // TikTok uses client_key instead of client_id } } default: @@ -1206,7 +1212,9 @@ function buildAuthRequest( headers.Authorization = `Basic ${basicAuth}` } else { // Use body credentials - include client credentials in request body - bodyParams.client_id = config.clientId + // Use custom param name if specified (e.g., TikTok uses 'client_key' instead of 'client_id') + const clientIdParam = config.clientIdParamName || 'client_id' + bodyParams[clientIdParam] = config.clientId if (config.clientSecret) { bodyParams.client_secret = config.clientSecret } diff --git a/apps/sim/lib/oauth/utils.ts b/apps/sim/lib/oauth/utils.ts index 989b0c3ce..bab256734 100644 --- a/apps/sim/lib/oauth/utils.ts +++ b/apps/sim/lib/oauth/utils.ts @@ -7,6 +7,49 @@ import type { ScopeEvaluation, } from './types' +// ============================================================================= +// Refresh Token Configuration +// ============================================================================= + +// Microsoft refresh token configuration (90 days) +const MICROSOFT_REFRESH_TOKEN_LIFETIME_DAYS = 90 +export const PROACTIVE_REFRESH_THRESHOLD_DAYS = 7 + +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) +} + +// TikTok refresh token configuration (365 days) +// TikTok access tokens expire in 24 hours, refresh tokens are valid for 365 days +const TIKTOK_REFRESH_TOKEN_LIFETIME_DAYS = 365 + +const TIKTOK_PROVIDERS = new Set(['tiktok']) + +export function isTikTokProvider(providerId: string): boolean { + return TIKTOK_PROVIDERS.has(providerId) +} + +export function getTikTokRefreshTokenExpiry(): Date { + return new Date(Date.now() + TIKTOK_REFRESH_TOKEN_LIFETIME_DAYS * 24 * 60 * 60 * 1000) +} + +// ============================================================================= +// OAuth Service Utilities +// ============================================================================= + /** * Returns a flat list of all available OAuth services with metadata. * This is safe to use on the server as it doesn't include React components.