优化
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user