如何实现可靠的SoundCloud令牌刷新流程?
问题描述
我在平台实现SoundCloud用户令牌刷新时遇到问题:仅过短短几天,用户令牌就被标记为false(无效),尽管我严格遵循SoundCloud推荐的令牌刷新流程。
根据SoundCloud官方文档:
Access token约1小时后过期,需使用
refresh_token定期刷新。
每个refresh_token仅可使用一次。
所有客户端均被视为保密客户端(confidential),即需要client_secret。
官方文档示例:
curl -X POST "https://secure.soundcloud.com/oauth/token" \ -H "accept: application/json; charset=utf-8" \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "grant_type=refresh_token" \ --data-urlencode "client_id=YOUR_CLIENT_ID" \ --data-urlencode "client_secret=YOUR_CLIENT_SECRET" \ --data-urlencode "refresh_token=YOUR_TOKEN"
尽管如此,我仍持续遇到刷新错误,最终用户/令牌状态变为false。我怀疑刷新逻辑或整体流程中存在疏漏(如令牌轮换、时机控制或新refresh_token的持久化),但无法准确定位问题。
请问如何实现可靠的SoundCloud令牌刷新流程?
当前使用的刷新函数
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.3"; // 检查令牌是否仍有效的辅助函数 function isTokenStillValid(expiresAt: string, bufferSeconds: number = 300): boolean { const expiryTime = new Date(expiresAt).getTime(); const now = Date.now(); const bufferMs = bufferSeconds * 1000; const isValid = expiryTime - now > bufferMs; if (!isValid) { console.log(`Token expired or expiring soon. Expires at: ${expiresAt}, Now: ${new Date().toISOString()}`); } return isValid; } export async function refreshSoundCloudToken(refreshToken: string, userId: string) { const supabaseUrl = Deno.env.get('SUPABASE_URL') ?? ''; const supabase = createClient( supabaseUrl, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '' ); // 记录后端标识用于调试 const urlHost = new URL(supabaseUrl).host; console.log(`🔗 Backend: ${urlHost} | User: ${userId}`); // 🔒 锁定:尝试通过数据库获取锁,防止多个进程同时刷新 const { data: lockAttempt, error: lockError } = await supabase .from('soundcloud_token_cache') .update({ refreshing: true, updated_at: new Date().toISOString() }) .eq('user_id', userId) .eq('refreshing', false) // 仅当未在刷新时更新 .select() .maybeSingle(); // 如果lockAttempt为null且无错误,检查是否存在记录 if (!lockAttempt && !lockError) { // 检查该用户是否存在记录 const { data: existingRow } = await supabase .from('soundcloud_token_cache') .select('user_id, refreshing') .eq('user_id', userId) .maybeSingle(); if (!existingRow) { // 无记录 = 该用户首次刷新,创建带锁的记录 console.log(`🔒 No cache row exists for user ${userId}, creating with lock...`); const { error: insertError } = await supabase .from('soundcloud_token_cache') .insert({ user_id: userId, access_token: 'pending_refresh', refresh_token: refreshToken, expires_at: new Date().toISOString(), // 已过期,将更新 refreshing: true, updated_at: new Date().toISOString() }); if (insertError) { console.error('Failed to create cache row with lock:', insertError); // 继续执行,尝试刷新 } } else if (existingRow.refreshing) { // 存在记录但已被其他进程锁定 console.log(`🔒 Another process is already refreshing token for user ${userId}, waiting...`); // 等待2秒后尝试重新读取缓存令牌 await new Promise(resolve => setTimeout(resolve, 2000)); const { data: cachedAfterWait } = await supabase .from('soundcloud_token_cache') .select('access_token, refresh_token, expires_at, refreshing') .eq('user_id', userId) .maybeSingle(); if (cachedAfterWait && !cachedAfterWait.refreshing && isTokenStillValid(cachedAfterWait.expires_at, 60)) { console.log(`✅ Token was refreshed by another process, using that`); return { access_token: cachedAfterWait.access_token, refresh_token: cachedAfterWait.refresh_token, expires_at: cachedAfterWait.expires_at, }; } } } // ✅ 检查1:获取缓存令牌(唯一可信源) const { data: cachedToken } = await supabase .from('soundcloud_token_cache') .select('access_token, refresh_token, expires_at, updated_at') .eq('user_id', userId) .maybeSingle(); // 🔒 去重:如果令牌在30秒内刚刷新,使用缓存 if (cachedToken?.updated_at) { const updatedAt = new Date(cachedToken.updated_at).getTime(); const secondsSinceUpdate = (Date.now() - updatedAt) / 1000; if (secondsSinceUpdate < 30 && isTokenStillValid(cachedToken.expires_at, 60)) { console.log(`🔒 Token was refreshed ${Math.round(secondsSinceUpdate)}s ago, using cached (race condition prevention)`); // 释放锁 await supabase .from('soundcloud_token_cache') .update({ refreshing: false }) .eq('user_id', userId); return { access_token: cachedToken.access_token, refresh_token: cachedToken.refresh_token, expires_at: cachedToken.expires_at, }; } } // ✅ 检查2:令牌是否仍有效?使用缓存 if (cachedToken && cachedToken.access_token !== 'pending_refresh' && isTokenStillValid(cachedToken.expires_at, 300)) { const minutesLeft = Math.round((new Date(cachedToken.expires_at).getTime() - Date.now()) / 1000 / 60); console.log(`✅ Using cached SoundCloud token from database (valid for ${minutesLeft} more minutes)`); // 释放锁 await supabase .from('soundcloud_token_cache') .update({ refreshing: false }) .eq('user_id', userId); return { access_token: cachedToken.access_token, refresh_token: cachedToken.refresh_token, expires_at: cachedToken.expires_at, }; } // 令牌已过期或不存在,立即刷新 console.log('🔄 Token expired or missing, refreshing SoundCloud token...'); const tokenToUse = cachedToken?.refresh_token || refreshToken; console.log(`🔑 Using refresh_token: ...${tokenToUse.slice(-6)} (last 6 chars for debugging)`); // 刷新前更新最后一次刷新尝试时间 await supabase .from('profiles') .update({ soundcloud_last_refresh_attempt: new Date().toISOString() }) .eq('id', userId); // 🔧 修复:同时在Basic Auth和请求体中发送凭证 const clientId = Deno.env.get('SOUNDCLOUD_CLIENT_ID') ?? ''; const clientSecret = Deno.env.get('SOUNDCLOUD_CLIENT_SECRET') ?? ''; const basicAuth = btoa(`${clientId}:${clientSecret}`); // 🔧 修复:使用统一的端点(secure.soundcloud.com) const tokenResponse = await fetch('https://secure.soundcloud.com/oauth/token', { method: 'POST', headers: { 'Authorization': `Basic ${basicAuth}`, 'Accept': 'application/json; charset=utf-8', 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ grant_type: 'refresh_token', client_id: clientId, client_secret: clientSecret, refresh_token: tokenToUse, }), }); if (!tokenResponse.ok) { const error = await tokenResponse.text(); console.error('❌ Token refresh failed:', error); console.error(` Used refresh_token ending in: ...${tokenToUse.slice(-6)}`); // 始终释放锁 await supabase .from('soundcloud_token_cache') .update({ refreshing: false }) .eq('user_id', userId); // 🔴 仅当确认是invalid_grant时,标记为无效 if (error.includes('invalid_grant')) { const errorMessage = 'Refresh token expired - user needs to reconnect'; const { error: updateError } = await supabase .from('profiles') .update({ soundcloud_token_valid: false, soundcloud_last_error: errorMessage }) .eq('id', userId); if (updateError) { console.error('❌ Failed to update profiles.soundcloud_token_valid:', updateError); } else { // 验证更新结果 const { data: verifyProfile } = await supabase .from('profiles') .select('soundcloud_token_valid, soundcloud_last_error') .eq('id', userId) .single(); if (verifyProfile) { console.log(`✅ Verified: token_valid=${verifyProfile.soundcloud_token_valid}, last_error="${verifyProfile.soundcloud_last_error}"`); } else { console.error('❌ Could not verify profile update'); } } } else { // 其他情况:可重试错误 await supabase .from('profiles') .update({ soundcloud_last_error: `Refresh failed (retryable): ${error.substring(0, 200)}` }) .eq('id', userId); } throw new Error(`Failed to refresh token: ${error}`); } const tokenData = await tokenResponse.json(); console.log('✅ Token refreshed successfully, expires in:', tokenData.expires_in, 'seconds'); console.log(`🔑 New refresh_token ending in: ...${tokenData.refresh_token?.slice(-6) || 'unchanged'}`); // 计算新的过期时间 const expiresAt = new Date(); if (tokenData.expires_in) { expiresAt.setSeconds(expiresAt.getSeconds() + tokenData.expires_in); } else { // 回退方案:1小时 expiresAt.setHours(expiresAt.getHours() + 1); } const newTokenData = { access_token: tokenData.access_token, refresh_token: tokenData.refresh_token || tokenToUse, // 🔧 修复:若无新令牌则使用旧令牌 expires_at: expiresAt.toISOString(), }; // 💾 更新缓存(唯一可信源)并释放锁 const { error: cacheError } = await supabase .from('soundcloud_token_cache') .upsert({ user_id: userId, access_token: newTokenData.access_token, refresh_token: newTokenData.refresh_token, expires_at: newTokenData.expires_at, updated_at: new Date().toISOString(), refreshing: false, // 🔒 释放锁 }, { onConflict: 'user_id' }); if (cacheError) { console.error('Warning: Failed to cache token:', cacheError); } else { console.log('💾 Token cached in soundcloud_token_cache'); } // 🔄 同步:更新PROFILES表 const { error: profileError } = await supabase .from('profiles') .update({ soundcloud_access_token: newTokenData.access_token, soundcloud_refresh_token: newTokenData.refresh_token, soundcloud_token_expires_at: newTokenData.expires_at, soundcloud_token_valid: true, soundcloud_last_error: null, soundcloud_last_refresh_attempt: new Date().toISOString(), }) .eq('id', userId); if (profileError) { console.error('Warning: Failed to update profiles table:', profileError); } else { console.log('💾 Token synced to profiles table'); } return newTokenData; }
问题诊断与解决方案
核心问题排查
你的代码已经覆盖了大部分基础场景,但仍有几个关键隐患会导致令牌短期失效:
重复使用旧refresh_token
SoundCloud明确要求每个refresh_token仅可使用一次,但当前锁机制存在漏洞:首次创建缓存记录失败时仍会继续执行刷新,可能导致无锁状态下多个进程使用同一旧令牌请求,触发SoundCloud风控机制标记令牌无效。refresh_token持久化原子性不足
缓存表与profiles表的更新顺序没有严格约束,若缓存更新失败但profiles表已更新,会导致令牌数据不一致,后续请求使用错误的refresh_token。错误处理误判令牌状态
遇到invalid_grant错误直接标记令牌无效,但该错误也可能由临时网络波动、请求参数错误导致,直接标记会误判用户状态。
修复方案
1. 强化锁机制,确保单一刷新进程
修改首次创建缓存记录的逻辑,只有成功获取锁后才执行刷新:
// 替换原有的首次创建记录逻辑 if (!existingRow) { console.log(`🔒 No cache row exists for user ${userId}, creating with lock...`); const { data: insertedRow, error: insertError } = await supabase .from('soundcloud_token_cache') .insert({ user_id: userId, access_token: 'pending_refresh', refresh_token: refreshToken, expires_at: new Date().toISOString(), refreshing: true, updated_at: new Date().toISOString() }) .select() .maybeSingle(); if (insertError || !insertedRow) { console.error('Failed to create cache row with lock:', insertError); // 创建失败则抛出错误,避免无锁刷新 throw new Error('Failed to acquire refresh lock'); } }
2. 严格保证refresh_token的原子更新
先更新缓存表,确认成功后再同步到profiles表,避免数据不一致:
// 修改缓存更新逻辑,确保成功后再同步到profiles const { error: cacheError } = await supabase .from('soundcloud_token_cache') .upsert({ user_id: userId, access_token: newTokenData.access_token, refresh_token: newTokenData.refresh_token, expires_at: newTokenData.expires_at, updated_at: new Date().toISOString(), refreshing: false, }, { onConflict: 'user_id' }); if (cacheError) { console.error('Failed to cache token, aborting:', cacheError); throw new Error('Failed to persist new token'); } // 缓存更新成功后再同步到profiles表 const { error: profileError } = await supabase .from('profiles') .update({ soundcloud_access_token: newTokenData.access_token, soundcloud_refresh_token: newTokenData.refresh_token, soundcloud_token_expires_at: newTokenData.expires_at, soundcloud_token_valid: true, soundcloud_last_error: null, soundcloud_last_refresh_attempt: new Date().toISOString(), }) .eq('id', userId);
3. 优化错误处理逻辑
针对invalid_grant错误增加重试机制,多次失败后再标记令牌无效:
if (!tokenResponse.ok) { const error = await tokenResponse.text(); console.error('❌ Token refresh failed:', error); console.error(` Used refresh_token ending in: ...${tokenToUse.slice(-6)}`); await supabase.from('soundcloud_token_cache').update({ refreshing: false }).eq('user_id', userId); if (error.includes('invalid_grant')) { // 获取当前重试次数 const { data: profile } = await supabase.from('profiles').select('soundcloud_refresh_retry_count').eq('id', userId).maybeSingle(); const retryCount = profile?.soundcloud_refresh_retry_count || 0; if (retryCount >= 2) { // 重试2次后仍失败,标记令牌无效 const errorMessage = 'Refresh token expired - user needs to reconnect'; await supabase.from('profiles').update({ soundcloud_token_valid: false, soundcloud_last_error: errorMessage

