added custom oauth for tiktok

This commit is contained in:
waleed
2026-02-05 13:26:26 -08:00
parent c02d2d10ce
commit 7fe7a85212
10 changed files with 407 additions and 90 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -294,6 +294,13 @@ const SCOPE_DESCRIPTIONS: Record<string, string> = {
'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,

View File

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

View File

@@ -1,4 +1,3 @@
export * from './microsoft'
export * from './oauth'
export * from './types'
export * from './utils'

View File

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

View File

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

View File

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