fix(auth): prevent refresh token rotation races

This commit is contained in:
litongtongxue@gmail.com
2026-08-02 08:13:15 -07:00
parent 7e2e9ba050
commit 38081ef72e
6 changed files with 540 additions and 107 deletions
+2
View File
@@ -1,6 +1,8 @@
.PHONY: build build-backend build-frontend test test-backend test-frontend test-frontend-critical
FRONTEND_CRITICAL_VITEST := \
src/api/__tests__/client.spec.ts \
src/api/__tests__/tokenRefresh.spec.ts \
src/views/auth/__tests__/LinuxDoCallbackView.spec.ts \
src/views/auth/__tests__/WechatCallbackView.spec.ts \
src/views/user/__tests__/PaymentView.spec.ts \
+88
View File
@@ -344,6 +344,94 @@ describe('API Client', () => {
writable: true,
})
})
it('有 refresh_token 时刷新并重试原请求', async () => {
localStorage.setItem('auth_token', 'expired-token')
localStorage.setItem('refresh_token', 'refresh-token')
localStorage.setItem('token_expires_at', String(Date.now() - 1))
localStorage.setItem('auth_user', JSON.stringify({ id: 7 }))
const adapter = vi.fn()
.mockRejectedValueOnce({
response: {
status: 401,
data: { code: 'TOKEN_EXPIRED', message: 'Token expired' },
},
config: {
url: '/test',
headers: { Authorization: 'Bearer expired-token' },
},
code: 'ERR_BAD_REQUEST',
})
.mockResolvedValueOnce({
status: 200,
data: { code: 0, data: { ok: true } },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
vi.spyOn(axios, 'post').mockResolvedValueOnce({
data: {
code: 0,
message: 'ok',
data: {
access_token: 'new-token',
refresh_token: 'new-refresh-token',
expires_in: 3600,
token_type: 'Bearer',
},
},
})
await expect(apiClient.get('/test')).resolves.toMatchObject({ data: { ok: true } })
expect(adapter).toHaveBeenCalledTimes(2)
expect(localStorage.getItem('auth_token')).toBe('new-token')
expect(localStorage.getItem('refresh_token')).toBe('new-refresh-token')
expect(adapter.mock.calls[1][0].headers.get('Authorization')).toBe('Bearer new-token')
})
it('刷新期间换号时旧请求不会清除新会话', async () => {
localStorage.setItem('auth_token', 'user-a-access')
localStorage.setItem('refresh_token', 'user-a-refresh')
localStorage.setItem('token_expires_at', String(Date.now() - 1))
localStorage.setItem('auth_user', JSON.stringify({ id: 7 }))
apiClient.defaults.adapter = vi.fn().mockRejectedValueOnce({
response: {
status: 401,
data: { code: 'TOKEN_EXPIRED', message: 'Token expired' },
},
config: {
url: '/test',
headers: { Authorization: 'Bearer user-a-access' },
},
code: 'ERR_BAD_REQUEST',
})
let rejectRefresh!: (reason: Error) => void
vi.spyOn(axios, 'post').mockImplementationOnce(
() => new Promise((_resolve, reject) => {
rejectRefresh = reject
})
)
const staleRequest = apiClient.get('/test')
await vi.waitFor(() => expect(axios.post).toHaveBeenCalledTimes(1))
localStorage.setItem('auth_token', 'user-b-access')
localStorage.setItem('refresh_token', 'user-b-refresh')
localStorage.setItem('token_expires_at', String(Date.now() + 3600_000))
localStorage.setItem('auth_user', JSON.stringify({ id: 8 }))
rejectRefresh(new Error('stale refresh failed'))
await expect(staleRequest).rejects.toMatchObject({ code: 'AUTH_SESSION_CHANGED' })
expect(localStorage.getItem('auth_token')).toBe('user-b-access')
expect(localStorage.getItem('refresh_token')).toBe('user-b-refresh')
expect(localStorage.getItem('auth_user')).toBe(JSON.stringify({ id: 8 }))
expect(window.location.pathname).toBe('/')
})
})
// --- 网络错误 ---
@@ -0,0 +1,180 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import axios from 'axios'
vi.mock('axios', () => ({
default: {
post: vi.fn()
}
}))
const mockedPost = vi.mocked(axios.post)
function seedSession(overrides: Partial<Record<string, string>> = {}): void {
localStorage.setItem('auth_token', overrides.auth_token || 'old-access')
localStorage.setItem('refresh_token', overrides.refresh_token || 'old-refresh')
localStorage.setItem('token_expires_at', overrides.token_expires_at || String(Date.now() - 1))
localStorage.setItem('auth_user', JSON.stringify({ id: 7, email: 'admin@example.com' }))
}
function refreshedResponse() {
return {
data: {
code: 0,
message: 'ok',
data: {
access_token: 'new-access',
refresh_token: 'new-refresh',
expires_in: 3600,
token_type: 'Bearer'
}
}
}
}
describe('refreshAuthTokens', () => {
beforeEach(() => {
localStorage.clear()
mockedPost.mockReset()
vi.resetModules()
Object.defineProperty(navigator, 'locks', {
configurable: true,
value: undefined
})
})
afterEach(() => {
vi.useRealTimers()
})
it('shares one refresh request between concurrent callers in the same document', async () => {
seedSession()
let resolveRequest!: (value: ReturnType<typeof refreshedResponse>) => void
mockedPost.mockImplementationOnce(
() => new Promise((resolve) => {
resolveRequest = resolve
})
)
const { refreshAuthTokens } = await import('@/api/tokenRefresh')
const first = refreshAuthTokens({ failedAccessToken: 'old-access' })
const second = refreshAuthTokens({ failedAccessToken: 'old-access' })
expect(mockedPost).toHaveBeenCalledTimes(1)
resolveRequest(refreshedResponse())
await expect(first).resolves.toMatchObject({ access_token: 'new-access' })
await expect(second).resolves.toMatchObject({ refresh_token: 'new-refresh' })
expect(localStorage.getItem('refresh_token')).toBe('new-refresh')
})
it('adopts tokens refreshed by another tab after acquiring the Web Lock', async () => {
seedSession()
const request = vi.fn(async (_name: string, callback: () => Promise<unknown>) => {
localStorage.setItem('auth_token', 'peer-access')
localStorage.setItem('token_expires_at', String(Date.now() + 3600_000))
localStorage.setItem('refresh_token', 'peer-refresh')
return callback()
})
Object.defineProperty(navigator, 'locks', {
configurable: true,
value: { request }
})
const { refreshAuthTokens } = await import('@/api/tokenRefresh')
const result = await refreshAuthTokens({ failedAccessToken: 'old-access' })
expect(request).toHaveBeenCalledTimes(1)
expect(mockedPost).not.toHaveBeenCalled()
expect(result).toMatchObject({
access_token: 'peer-access',
refresh_token: 'peer-refresh'
})
})
it('recovers when a peer publishes the rotated token just after this request fails', async () => {
seedSession()
mockedPost.mockRejectedValueOnce(new Error('refresh token already used'))
const { refreshAuthTokens } = await import('@/api/tokenRefresh')
window.setTimeout(() => {
localStorage.setItem('auth_token', 'peer-access')
localStorage.setItem('token_expires_at', String(Date.now() + 3600_000))
localStorage.setItem('refresh_token', 'peer-refresh')
}, 10)
await expect(
refreshAuthTokens({ failedAccessToken: 'old-access' })
).resolves.toMatchObject({
access_token: 'peer-access',
refresh_token: 'peer-refresh'
})
})
it('waits for a slow peer after losing a refresh-token race without Web Locks', async () => {
vi.useFakeTimers()
seedSession()
let resolveWinningRequest!: (value: ReturnType<typeof refreshedResponse>) => void
mockedPost.mockImplementationOnce(
() => new Promise((resolve) => {
resolveWinningRequest = resolve
})
)
const firstTab = await import('@/api/tokenRefresh')
vi.resetModules()
const secondTab = await import('@/api/tokenRefresh')
const winner = firstTab.refreshAuthTokens({ failedAccessToken: 'old-access' })
mockedPost.mockRejectedValueOnce({ response: { status: 401 } })
const loser = secondTab.refreshAuthTokens({ failedAccessToken: 'old-access' })
window.setTimeout(() => resolveWinningRequest(refreshedResponse()), 1_500)
await vi.advanceTimersByTimeAsync(1_600)
await expect(winner).resolves.toMatchObject({ access_token: 'new-access' })
await expect(loser).resolves.toMatchObject({ refresh_token: 'new-refresh' })
expect(mockedPost).toHaveBeenCalledTimes(2)
expect(localStorage.getItem('refresh_token')).toBe('new-refresh')
})
it('does not adopt a token from a different signed-in user', async () => {
vi.useFakeTimers()
seedSession()
mockedPost.mockRejectedValueOnce(new Error('refresh token already used'))
const { refreshAuthTokens } = await import('@/api/tokenRefresh')
window.setTimeout(() => {
localStorage.setItem('auth_user', JSON.stringify({ id: 8, email: 'other@example.com' }))
localStorage.setItem('auth_token', 'other-access')
localStorage.setItem('token_expires_at', String(Date.now() + 3600_000))
localStorage.setItem('refresh_token', 'other-refresh')
}, 10)
const rejection = expect(
refreshAuthTokens({ failedAccessToken: 'old-access' })
).rejects.toThrow('refresh token already used')
await vi.advanceTimersByTimeAsync(1_100)
await rejection
})
it('does not restore a session that was logged out while refresh was in flight', async () => {
vi.useFakeTimers()
seedSession()
let resolveRequest!: (value: ReturnType<typeof refreshedResponse>) => void
mockedPost.mockImplementationOnce(
() => new Promise((resolve) => {
resolveRequest = resolve
})
)
const { refreshAuthTokens } = await import('@/api/tokenRefresh')
const pending = refreshAuthTokens({ failedAccessToken: 'old-access' })
localStorage.clear()
resolveRequest(refreshedResponse())
const rejection = expect(pending).rejects.toThrow('Session changed during token refresh')
await vi.advanceTimersByTimeAsync(1_100)
await rejection
expect(localStorage.getItem('auth_token')).toBeNull()
expect(localStorage.getItem('refresh_token')).toBeNull()
})
})
+3 -22
View File
@@ -4,6 +4,8 @@
*/
import { apiClient } from './client'
import { refreshAuthTokens, type RefreshTokenResponse } from './tokenRefresh'
export type { RefreshTokenResponse } from './tokenRefresh'
import type {
LoginRequest,
RegisterRequest,
@@ -179,13 +181,6 @@ export async function logout(): Promise<void> {
/**
* Refresh token response
*/
export interface RefreshTokenResponse {
access_token: string
refresh_token: string
expires_in: number
token_type: string
}
export interface OAuthTokenResponse {
access_token: string
refresh_token?: string
@@ -293,21 +288,7 @@ export async function prepareOAuthBindAccessTokenCookie(): Promise<void> {
* @returns New token pair
*/
export async function refreshToken(): Promise<RefreshTokenResponse> {
const currentRefreshToken = getRefreshToken()
if (!currentRefreshToken) {
throw new Error('No refresh token available')
}
const { data } = await apiClient.post<RefreshTokenResponse>('/auth/refresh', {
refresh_token: currentRefreshToken
})
// Update tokens in localStorage
setAuthToken(data.access_token)
setRefreshToken(data.refresh_token)
setTokenExpiresAt(data.expires_in)
return data
return refreshAuthTokens()
}
/**
+26 -85
View File
@@ -12,6 +12,7 @@ import {
shouldMarkAdminUIRequest,
shouldMarkUserUIRequest,
} from './adminUIRequest'
import { refreshAuthTokens } from './tokenRefresh'
import { getAPIBaseURL } from './url'
export { buildApiUrl, buildGatewayUrl } from './url'
@@ -26,28 +27,6 @@ export const apiClient: AxiosInstance = axios.create({
}
})
// ==================== Token Refresh State ====================
// Track if a token refresh is in progress to prevent multiple simultaneous refresh requests
let isRefreshing = false
// Queue of requests waiting for token refresh
let refreshSubscribers: Array<(token: string) => void> = []
/**
* Subscribe to token refresh completion
*/
function subscribeTokenRefresh(callback: (token: string) => void): void {
refreshSubscribers.push(callback)
}
/**
* Notify all subscribers that token has been refreshed
*/
function onTokenRefreshed(token: string): void {
refreshSubscribers.forEach((callback) => callback(token))
refreshSubscribers = []
}
// ==================== Request Interceptor ====================
// Get user's timezone
@@ -190,74 +169,36 @@ apiClient.interceptors.response.use(
// If we have a refresh token and this is not an auth endpoint, try to refresh
if (refreshToken && !isAuthEndpoint) {
if (isRefreshing) {
// Wait for the ongoing refresh to complete
return new Promise((resolve, reject) => {
subscribeTokenRefresh((newToken: string) => {
if (newToken) {
// Mark as retried to prevent infinite loop if retry also returns 401
originalRequest._retry = true
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newToken}`
}
resolve(apiClient(originalRequest))
} else {
// Refresh failed, reject with original error
reject({
status,
code: apiData.code,
message: apiData.message || apiData.detail || error.message
})
}
})
})
}
const refreshSessionUser = localStorage.getItem('auth_user')
originalRequest._retry = true
isRefreshing = true
try {
// Call refresh endpoint directly to avoid circular dependency
const refreshResponse = await axios.post(
`${getAPIBaseURL()}/auth/refresh`,
{ refresh_token: refreshToken },
// 显式设置超时:裸 axios 默认无限等待,若刷新请求挂起会导致 isRefreshing
// 永远为 true,所有排队的 401 重试请求永久卡死,页面 loading 无法恢复。
{ headers: { 'Content-Type': 'application/json' }, timeout: 30000 }
)
const headers = originalRequest.headers as Record<string, unknown> | undefined
const authHeader = headers?.Authorization ?? headers?.authorization
const failedAccessToken =
typeof authHeader === 'string' && authHeader.startsWith('Bearer ')
? authHeader.slice('Bearer '.length)
: null
const tokens = await refreshAuthTokens({ failedAccessToken })
const refreshData = refreshResponse.data as ApiResponse<{
access_token: string
refresh_token: string
expires_in: number
}>
if (refreshData.code === 0 && refreshData.data) {
const { access_token, refresh_token: newRefreshToken, expires_in } = refreshData.data
// Update tokens in localStorage (convert expires_in to timestamp)
localStorage.setItem('auth_token', access_token)
localStorage.setItem('refresh_token', newRefreshToken)
localStorage.setItem('token_expires_at', String(Date.now() + expires_in * 1000))
// Notify subscribers with new token
onTokenRefreshed(access_token)
// Retry the original request with new token
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${access_token}`
}
isRefreshing = false
return apiClient(originalRequest)
// Retry the original request with the refreshed token
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${tokens.access_token}`
}
return apiClient(originalRequest)
} catch {
// A stale request must never destroy a session that was logged out or replaced while
// its refresh was in flight (for example, when another tab signs in as another user).
const sessionChanged =
localStorage.getItem('refresh_token') !== refreshToken ||
localStorage.getItem('auth_user') !== refreshSessionUser
if (sessionChanged) {
return Promise.reject({
status: 401,
code: 'AUTH_SESSION_CHANGED',
message: 'Authentication session changed while refreshing.'
})
}
// Refresh response was not successful, fall through to clear auth
throw new Error('Token refresh failed')
} catch (refreshError) {
// Refresh failed - notify subscribers with empty token
onTokenRefreshed('')
isRefreshing = false
// Clear tokens and redirect to login
localStorage.removeItem('auth_token')
+241
View File
@@ -0,0 +1,241 @@
import axios from 'axios'
import type { ApiResponse } from '@/types'
import { getAPIBaseURL } from './url'
const AUTH_TOKEN_KEY = 'auth_token'
const AUTH_USER_KEY = 'auth_user'
const REFRESH_TOKEN_KEY = 'refresh_token'
const TOKEN_EXPIRES_AT_KEY = 'token_expires_at'
const TOKEN_REFRESH_LOCK_NAME = 'sub2api-auth-token-refresh'
const TOKEN_REFRESH_TIMEOUT_MS = 30_000
const TOKEN_REFRESH_BUFFER_MS = 120_000
const PEER_REFRESH_WAIT_MS = 1_000
const PEER_REFRESH_GRACE_MS = 1_000
const PEER_REFRESH_POLL_MS = 25
export interface RefreshTokenResponse {
access_token: string
refresh_token: string
expires_in: number
token_type: string
}
export interface RefreshAuthTokensOptions {
/** Access token attached to the request that received a 401 response. */
failedAccessToken?: string | null
}
interface AuthSnapshot {
accessToken: string | null
refreshToken: string
expiresAt: number
userID: number | null
}
let inFlightRefresh: Promise<RefreshTokenResponse> | null = null
function getStoredUserID(): number | null {
const rawUser = localStorage.getItem(AUTH_USER_KEY)
if (!rawUser) {
return null
}
try {
const id = Number((JSON.parse(rawUser) as { id?: unknown }).id)
return Number.isFinite(id) && id > 0 ? id : null
} catch {
return null
}
}
function readAuthSnapshot(): AuthSnapshot {
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY)
if (!refreshToken) {
throw new Error('No refresh token available')
}
return {
accessToken: localStorage.getItem(AUTH_TOKEN_KEY),
refreshToken,
expiresAt: Number(localStorage.getItem(TOKEN_EXPIRES_AT_KEY)),
userID: getStoredUserID()
}
}
function readStoredTokenPair(snapshot: AuthSnapshot): RefreshTokenResponse | null {
const accessToken = localStorage.getItem(AUTH_TOKEN_KEY)
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY)
const expiresAt = Number(localStorage.getItem(TOKEN_EXPIRES_AT_KEY))
if (
!accessToken ||
!refreshToken ||
!Number.isFinite(expiresAt) ||
expiresAt <= Date.now() ||
getStoredUserID() !== snapshot.userID
) {
return null
}
return {
access_token: accessToken,
refresh_token: refreshToken,
expires_in: Math.max(1, Math.ceil((expiresAt - Date.now()) / 1000)),
token_type: 'Bearer'
}
}
function readPeerRefreshResult(
snapshot: AuthSnapshot,
failedAccessToken?: string | null
): RefreshTokenResponse | null {
const storedPair = readStoredTokenPair(snapshot)
if (!storedPair) {
return null
}
if (storedPair.refresh_token !== snapshot.refreshToken) {
return storedPair
}
if (
failedAccessToken &&
snapshot.accessToken !== failedAccessToken &&
storedPair.access_token === snapshot.accessToken
) {
return storedPair
}
if (!failedAccessToken) {
const expiresAt = Number(localStorage.getItem(TOKEN_EXPIRES_AT_KEY))
if (
expiresAt === snapshot.expiresAt &&
storedPair.access_token === snapshot.accessToken &&
expiresAt > Date.now() + TOKEN_REFRESH_BUFFER_MS
) {
return storedPair
}
}
return null
}
async function waitForPeerRefresh(
snapshot: AuthSnapshot,
failedAccessToken?: string | null,
deadline = Date.now() + PEER_REFRESH_WAIT_MS
): Promise<RefreshTokenResponse | null> {
while (Date.now() < deadline) {
const peerResult = readPeerRefreshResult(snapshot, failedAccessToken)
if (peerResult) {
return peerResult
}
await new Promise((resolve) => window.setTimeout(resolve, PEER_REFRESH_POLL_MS))
}
return readPeerRefreshResult(snapshot, failedAccessToken)
}
function persistTokenPair(tokens: RefreshTokenResponse): void {
localStorage.setItem(AUTH_TOKEN_KEY, tokens.access_token)
localStorage.setItem(TOKEN_EXPIRES_AT_KEY, String(Date.now() + tokens.expires_in * 1000))
// The rotating refresh token is written last so other tabs can treat its change as a commit marker.
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token)
}
async function requestTokenPair(
snapshot: AuthSnapshot,
failedAccessToken?: string | null,
mayHaveUncoordinatedPeer = false
): Promise<RefreshTokenResponse> {
// If this request loses a one-time-token race, the winning peer can legitimately take as long
// as our own HTTP timeout to publish its replacement token. Keep the recovery window tied to
// that timeout instead of an arbitrary short delay.
const peerRefreshDeadline = Date.now() + TOKEN_REFRESH_TIMEOUT_MS + PEER_REFRESH_GRACE_MS
try {
const response = await axios.post<ApiResponse<RefreshTokenResponse>>(
`${getAPIBaseURL()}/auth/refresh`,
{ refresh_token: snapshot.refreshToken },
{ headers: { 'Content-Type': 'application/json' }, timeout: TOKEN_REFRESH_TIMEOUT_MS }
)
const payload = response.data
if (payload.code !== 0 || !payload.data) {
throw new Error(payload.message || 'Token refresh failed')
}
if (
localStorage.getItem(REFRESH_TOKEN_KEY) !== snapshot.refreshToken ||
getStoredUserID() !== snapshot.userID
) {
const peerResult = readPeerRefreshResult(snapshot, failedAccessToken)
if (peerResult) {
return peerResult
}
throw new Error('Session changed during token refresh')
}
persistTokenPair(payload.data)
return payload.data
} catch (error) {
// A peer tab may have rotated the one-time refresh token while this request was in flight.
// A 4xx response can arrive quickly while the winning peer's response is still in flight, so
// wait through the shared request deadline before treating the session as expired. Transient
// non-4xx failures retain the short reconciliation window.
const responseStatus = (error as { response?: { status?: unknown } }).response?.status
const isTokenRejection =
typeof responseStatus === 'number' && responseStatus >= 400 && responseStatus < 500
const peerResult = await waitForPeerRefresh(
snapshot,
failedAccessToken,
isTokenRejection && mayHaveUncoordinatedPeer
? peerRefreshDeadline
: Date.now() + PEER_REFRESH_WAIT_MS
)
if (peerResult) {
return peerResult
}
throw error
}
}
async function runRefresh(options: RefreshAuthTokensOptions): Promise<RefreshTokenResponse> {
const snapshot = readAuthSnapshot()
const refresh = async (mayHaveUncoordinatedPeer = false): Promise<RefreshTokenResponse> => {
const peerResult = readPeerRefreshResult(snapshot, options.failedAccessToken)
if (peerResult) {
return peerResult
}
return requestTokenPair(snapshot, options.failedAccessToken, mayHaveUncoordinatedPeer)
}
if (typeof navigator !== 'undefined' && navigator.locks) {
return navigator.locks.request(TOKEN_REFRESH_LOCK_NAME, () => refresh(false))
}
return refresh(true)
}
/**
* Refresh and persist the browser session.
*
* Calls in the same document share one promise. Web Locks serialize refreshes across tabs, while
* the token snapshot check adopts a peer's newly rotated token instead of logging the user out.
*/
export function refreshAuthTokens(
options: RefreshAuthTokensOptions = {}
): Promise<RefreshTokenResponse> {
if (inFlightRefresh) {
return inFlightRefresh
}
const pending = runRefresh(options)
inFlightRefresh = pending
const clearPending = (): void => {
if (inFlightRefresh === pending) {
inFlightRefresh = null
}
}
void pending.then(clearPending, clearPending)
return pending
}