mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 13:18:18 +08:00
Merge pull request #5200 from mrlitong/fix/auth-refresh-race
fix(auth): prevent refresh token races across tabs
This commit is contained in:
@@ -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 \
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user