This commit is contained in:
2026-08-07 14:47:00 +08:00
parent 54fe05d864
commit 0bd872fdbf
12 changed files with 461 additions and 36 deletions
+78
View File
@@ -0,0 +1,78 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { coordinateCrossTabRefresh } from './crossTabRefresh.ts'
test('reuses tokens rotated by another browser tab while waiting for the refresh lock', async () => {
let currentRefreshToken = 'refresh-old'
let refreshCalls = 0
const originalNavigator = globalThis.navigator
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: {
locks: {
async request(_name, callback) {
currentRefreshToken = 'refresh-new'
return callback()
},
},
},
})
try {
const result = await coordinateCrossTabRefresh({
lockName: 'client-refresh',
observedRefreshToken: 'refresh-old',
currentRefreshToken: () => currentRefreshToken,
reuseCurrentSession: async () => 'access-new',
refresh: async () => {
refreshCalls += 1
return 'unexpected'
},
})
assert.equal(result, 'access-new')
assert.equal(refreshCalls, 0)
} finally {
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator })
}
})
test('refreshes normally when this tab owns the current refresh token', async () => {
const originalNavigator = globalThis.navigator
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: { locks: { request: async (_name, callback) => callback() } },
})
try {
const result = await coordinateCrossTabRefresh({
lockName: 'client-refresh',
observedRefreshToken: 'refresh-old',
currentRefreshToken: () => 'refresh-old',
reuseCurrentSession: async () => 'unexpected',
refresh: async (refreshToken) => `access-for-${refreshToken}`,
})
assert.equal(result, 'access-for-refresh-old')
} finally {
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator })
}
})
test('does not wait for rotation after the shared session was removed', async () => {
const originalNavigator = globalThis.navigator
const refreshError = new Error('session removed')
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: {} })
try {
await assert.rejects(coordinateCrossTabRefresh({
lockName: 'client-refresh',
observedRefreshToken: 'refresh-old',
currentRefreshToken: () => '',
reuseCurrentSession: async () => 'unexpected',
refresh: async () => { throw refreshError },
}), refreshError)
} finally {
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator })
}
})
+37
View File
@@ -0,0 +1,37 @@
export interface CrossTabRefreshOptions<T> {
lockName: string
observedRefreshToken: string
currentRefreshToken: () => string
reuseCurrentSession: () => Promise<T>
refresh: (refreshToken: string) => Promise<T>
}
export async function coordinateCrossTabRefresh<T>(options: CrossTabRefreshOptions<T>): Promise<T> {
const run = async () => {
const currentRefreshToken = options.currentRefreshToken()
if (currentRefreshToken && currentRefreshToken !== options.observedRefreshToken) {
return options.reuseCurrentSession()
}
return options.refresh(currentRefreshToken || options.observedRefreshToken)
}
if (typeof navigator === 'undefined' || !navigator.locks) {
try {
return await run()
} catch (error) {
if (!options.currentRefreshToken()) throw error
// Web Locks requires a secure context. On plain HTTP, briefly allow another tab's
// successful rotation to reach shared storage before treating this session as expired.
for (let attempt = 0; attempt < 20; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 50))
const currentRefreshToken = options.currentRefreshToken()
if (!currentRefreshToken) throw error
if (currentRefreshToken && currentRefreshToken !== options.observedRefreshToken) {
return options.reuseCurrentSession()
}
}
throw error
}
}
return navigator.locks.request(options.lockName, run)
}
+22 -11
View File
@@ -3,6 +3,7 @@ import { parseApiJsonResponse } from '@chongchong/shared'
import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair, type TokenPair } from '@/api/session'
import { apiErrorMessage, asFieldErrors } from '@/api/errorDetails'
import { ApiError, isApiEnvelope, type ApiEnvelope } from '@/api/types'
import { coordinateCrossTabRefresh } from '@/api/crossTabRefresh'
const transport = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
@@ -42,7 +43,7 @@ function mapError(error: unknown) {
return new ApiError(error.message || '请求失败', 'HTTP_ERROR', error.response?.status)
}
let refreshPromise: Promise<TokenPair> | null = null
let refreshPromise: Promise<string> | null = null
let redirectingToLogin = false
function redirectToLoginAfterSessionExpiry() {
@@ -57,16 +58,26 @@ function redirectToLoginAfterSessionExpiry() {
}
async function refreshSession() {
const refreshToken = getRefreshToken()
if (!refreshToken) throw new ApiError('登录已过期,请重新登录', 'A0401', 401)
const observedRefreshToken = getRefreshToken()
if (!observedRefreshToken) throw new ApiError('登录已过期,请重新登录', 'A0401', 401)
if (!refreshPromise) {
refreshPromise = transport
.post<ApiEnvelope<TokenPair>>('/auth/refresh', { refreshToken })
.then((response) => unwrap(response.data))
.then((tokens) => {
refreshPromise = coordinateCrossTabRefresh({
lockName: 'cc-client-auth-refresh',
observedRefreshToken,
currentRefreshToken: getRefreshToken,
reuseCurrentSession: async () => {
const currentAccessToken = getAccessToken()
if (!currentAccessToken) throw new ApiError('登录已过期,请重新登录', 'A0401', 401)
return currentAccessToken
},
refresh: async (currentRefreshToken) => {
const response = await transport.post<ApiEnvelope<TokenPair>>('/auth/refresh', { refreshToken: currentRefreshToken })
const tokens = unwrap(response.data)
if (!getRefreshToken()) throw new ApiError('登录已在其他标签页退出', 'A0401', 401)
persistTokenPair(tokens)
return tokens
})
return tokens.accessToken
},
})
.finally(() => {
refreshPromise = null
})
@@ -92,8 +103,8 @@ transport.interceptors.response.use(
request._retriedAfterRefresh = true
try {
const tokens = await refreshSession()
request.headers.Authorization = `Bearer ${tokens.accessToken}`
const accessToken = await refreshSession()
request.headers.Authorization = `Bearer ${accessToken}`
return await transport.request(request)
} catch (refreshError) {
const mappedError = mapError(refreshError)