fix(auth): restore cross-tab permissions and bind retries to session identity
This commit is contained in:
+34
-3
@@ -1,6 +1,6 @@
|
||||
import axios, { AxiosError, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { parseApiJsonResponse } from '@chongchong/shared'
|
||||
import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair, type TokenPair } from '@/api/session'
|
||||
import { accessTokenIdentity, clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair, type TokenPair } from '@/api/session'
|
||||
import { apiErrorMessage, asFieldErrors } from '@/api/errorDetails'
|
||||
import { ApiError, isApiEnvelope, isApiFailure, type ApiEnvelope } from '@/api/types'
|
||||
import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from '@/api/crossTabRefresh'
|
||||
@@ -13,6 +13,7 @@ const transport = axios.create({
|
||||
|
||||
interface RetryableRequest extends InternalAxiosRequestConfig {
|
||||
_retriedAfterRefresh?: boolean
|
||||
_sessionIdentity?: string | null
|
||||
}
|
||||
|
||||
function unwrap<T>(envelope: ApiEnvelope<T>) {
|
||||
@@ -52,6 +53,7 @@ function mapError(error: unknown) {
|
||||
}
|
||||
|
||||
let refreshPromise: Promise<string> | null = null
|
||||
let refreshIdentity: string | null = null
|
||||
let redirectingToLogin = false
|
||||
const publicAuthPaths = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/impersonations/exchange']
|
||||
|
||||
@@ -70,29 +72,44 @@ function redirectToLoginAfterSessionExpiry() {
|
||||
window.location.replace(`/login?redirect=${encodeURIComponent(redirect)}`)
|
||||
}
|
||||
|
||||
function requireCurrentIdentity(identity: string | null | undefined, accessToken = getAccessToken()) {
|
||||
if (!identity || accessTokenIdentity(accessToken) !== identity) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED')
|
||||
}
|
||||
|
||||
export async function refreshAuthenticatedSession() {
|
||||
const observedIdentity = accessTokenIdentity(getAccessToken())
|
||||
requireCurrentIdentity(observedIdentity)
|
||||
const observedRefreshToken = getRefreshToken()
|
||||
if (!observedRefreshToken) throw new ApiError('登录已过期,请重新登录', 'A0401', 401)
|
||||
if (!refreshPromise) {
|
||||
refreshIdentity = observedIdentity
|
||||
refreshPromise = coordinateCrossTabRefresh({
|
||||
lockName: 'cc-client-auth-refresh',
|
||||
observedRefreshToken,
|
||||
currentRefreshToken: getRefreshToken,
|
||||
reuseCurrentSession: async () => {
|
||||
requireCurrentIdentity(observedIdentity)
|
||||
const currentAccessToken = getAccessToken()
|
||||
if (!currentAccessToken) throw new ApiError('登录已过期,请重新登录', 'A0401', 401)
|
||||
return currentAccessToken
|
||||
},
|
||||
refresh: async (currentRefreshToken) => {
|
||||
requireCurrentIdentity(observedIdentity)
|
||||
const response = await transport.post<ApiEnvelope<TokenPair>>('/auth/refresh', { refreshToken: currentRefreshToken })
|
||||
const tokens = unwrap(response.data)
|
||||
requireCurrentIdentity(observedIdentity)
|
||||
requireCurrentIdentity(observedIdentity, tokens.accessToken)
|
||||
if (!getRefreshToken()) throw new ApiError('登录已在其他标签页退出', 'A0401', 401)
|
||||
if (getRefreshToken() !== currentRefreshToken) throw new ApiError('登录状态已变化,请重新操作', 'SESSION_CHANGED')
|
||||
persistTokenPair(tokens)
|
||||
if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('cc-client-session-updated'))
|
||||
return tokens.accessToken
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
requireCurrentIdentity(observedIdentity)
|
||||
if (error instanceof CrossTabRefreshTimeoutError) {
|
||||
if (getRefreshToken() && getRefreshToken() !== observedRefreshToken) throw new ApiError('登录状态已变化,请重新操作', 'SESSION_CHANGED')
|
||||
const expiredError = new ApiError(error.message, 'A0401', 401)
|
||||
clearSessionTokens()
|
||||
window.dispatchEvent(new CustomEvent('cc-client-session-expired'))
|
||||
@@ -103,14 +120,23 @@ export async function refreshAuthenticatedSession() {
|
||||
})
|
||||
.finally(() => {
|
||||
refreshPromise = null
|
||||
refreshIdentity = null
|
||||
})
|
||||
}
|
||||
return refreshPromise
|
||||
if (refreshIdentity !== observedIdentity) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED')
|
||||
const refreshed = await refreshPromise
|
||||
requireCurrentIdentity(observedIdentity)
|
||||
return refreshed
|
||||
}
|
||||
|
||||
transport.interceptors.request.use((config) => {
|
||||
const token = getAccessToken()
|
||||
if (token && !isPublicAuthRequest(config.url)) config.headers.Authorization = `Bearer ${token}`
|
||||
const request = config as RetryableRequest
|
||||
if (request._retriedAfterRefresh && !isPublicAuthRequest(config.url)) requireCurrentIdentity(request._sessionIdentity, token)
|
||||
if (token && !isPublicAuthRequest(config.url)) {
|
||||
if (!request._retriedAfterRefresh) request._sessionIdentity = accessTokenIdentity(token)
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
@@ -122,14 +148,19 @@ transport.interceptors.response.use(
|
||||
throw mapError(error)
|
||||
}
|
||||
|
||||
requireCurrentIdentity(request._sessionIdentity)
|
||||
request._retriedAfterRefresh = true
|
||||
const refreshTokenBeforeRetry = getRefreshToken()
|
||||
try {
|
||||
const accessToken = await refreshAuthenticatedSession()
|
||||
requireCurrentIdentity(request._sessionIdentity)
|
||||
request.headers.Authorization = `Bearer ${accessToken}`
|
||||
return await transport.request(request)
|
||||
} catch (refreshError) {
|
||||
requireCurrentIdentity(request._sessionIdentity)
|
||||
const mappedError = mapError(refreshError)
|
||||
if (mappedError.status === 401 || mappedError.code === 'A0401') {
|
||||
if (getRefreshToken() && getRefreshToken() !== refreshTokenBeforeRetry) throw mappedError
|
||||
clearSessionTokens()
|
||||
window.dispatchEvent(new CustomEvent('cc-client-session-expired'))
|
||||
redirectToLoginAfterSessionExpiry()
|
||||
|
||||
@@ -56,3 +56,15 @@ test('remembers only the login id and keeps it when session tokens are cleared',
|
||||
persistRememberedLoginId('merchant-user', false)
|
||||
assert.equal(getRememberedLoginId(), '')
|
||||
})
|
||||
|
||||
test('normal token rotation never removes either shared token between writes', () => {
|
||||
installStorage()
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, 'old-access')
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, 'old-refresh')
|
||||
const removed = []
|
||||
const remove = localStorage.removeItem
|
||||
localStorage.removeItem = key => { removed.push(key); remove(key) }
|
||||
persistTokenPair({ accessToken: 'new-access', refreshToken: 'new-refresh' })
|
||||
assert.ok(!removed.includes(ACCESS_TOKEN_KEY))
|
||||
assert.ok(!removed.includes(REFRESH_TOKEN_KEY))
|
||||
})
|
||||
|
||||
+21
-1
@@ -19,9 +19,29 @@ export function getRefreshToken() {
|
||||
return localStorage.getItem(REFRESH_TOKEN_KEY) || sessionStorage.getItem(REFRESH_TOKEN_KEY) || ''
|
||||
}
|
||||
|
||||
// Only binds a retry to its original identity; never grants permissions. Expired
|
||||
// access tokens remain readable here because they are the normal refresh trigger.
|
||||
export function accessTokenIdentity(token: string): string | null {
|
||||
try {
|
||||
const encoded = token.split('.')[1]
|
||||
if (!encoded) return null
|
||||
const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const bytes = Uint8Array.from(atob(normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')), character => character.charCodeAt(0))
|
||||
const value = JSON.parse(new TextDecoder().decode(bytes))
|
||||
if (!value?.user_id || !value.tenant_id || !value.membership_id || value.app_code !== 'MERCHANT') return null
|
||||
return JSON.stringify([value.user_id, value.tenant_id, value.membership_id, value.operator_tenant_id, value.app_code, value.client_type].map(item => item == null ? null : String(item)))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function persistTokenPair(tokens: TokenPair) {
|
||||
clearSessionTokens()
|
||||
// Replace the pair without publishing a transient logout to other tabs.
|
||||
sessionStorage.removeItem(ACCESS_TOKEN_KEY)
|
||||
sessionStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||
sessionStorage.removeItem('cc-client-token')
|
||||
localStorage.removeItem('cc-client-token')
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken)
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken)
|
||||
}
|
||||
|
||||
@@ -18,13 +18,21 @@ function compile(name, dependencies = {}) {
|
||||
return exports
|
||||
}
|
||||
|
||||
function access(user, tenant = 1, membership = 1, nonce = 'original') {
|
||||
return `test.${Buffer.from(JSON.stringify({ user_id: user, tenant_id: tenant, membership_id: membership, app_code: 'MERCHANT', nonce })).toString('base64url')}.test`
|
||||
}
|
||||
const oldAccess = access(1)
|
||||
const newAccess = access(1, 1, 1, 'rotated')
|
||||
const otherAccess = access(2, 2, 2)
|
||||
|
||||
function harness(adapter) {
|
||||
let token = 'old-access'
|
||||
let token = oldAccess
|
||||
let refreshToken = 'old-refresh'
|
||||
const http = compile('./http.ts', {
|
||||
axios: { ...axios, create: (config) => axios.create({ ...config, adapter }) },
|
||||
'@chongchong/shared': { parseApiJsonResponse: (data) => data },
|
||||
'@/api/session': {
|
||||
accessTokenIdentity: compile('./session.ts').accessTokenIdentity,
|
||||
getAccessToken: () => token, getRefreshToken: () => refreshToken,
|
||||
persistTokenPair: (tokens) => { token = tokens.accessToken; refreshToken = tokens.refreshToken },
|
||||
clearSessionTokens: () => { token = ''; refreshToken = '' },
|
||||
@@ -33,7 +41,7 @@ function harness(adapter) {
|
||||
'@/api/types': compile('./types.ts'),
|
||||
'@/api/crossTabRefresh': compile('./crossTabRefresh.ts'),
|
||||
})
|
||||
return http
|
||||
return { ...http, setSession: (access, refresh) => { token = access; refreshToken = refresh }, session: () => ({ token, refreshToken }) }
|
||||
}
|
||||
|
||||
test('order and lastleg 401s share one refresh and replay the unified access token', async () => {
|
||||
@@ -44,9 +52,9 @@ test('order and lastleg 401s share one refresh and replay the unified access tok
|
||||
if (config.url === '/auth/refresh') {
|
||||
refreshes++
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
return { config, status: 200, headers: {}, data: { code: '200', message: 'OK', data: { accessToken: 'new-access', refreshToken: 'new-refresh' } } }
|
||||
return { config, status: 200, headers: {}, data: { code: '200', message: 'OK', data: { accessToken: newAccess, refreshToken: 'new-refresh' } } }
|
||||
}
|
||||
if (config.headers.Authorization === 'Bearer old-access') {
|
||||
if (config.headers.Authorization === `Bearer ${oldAccess}`) {
|
||||
throw new axios.AxiosError('Expired', 'ERR_BAD_REQUEST', config, null, { status: 401, data: { code: 'A0401', message: 'Expired' } })
|
||||
}
|
||||
return { config, status: 200, headers: {}, data: { code: '200', message: 'OK', data: config.url } }
|
||||
@@ -56,7 +64,7 @@ test('order and lastleg 401s share one refresh and replay the unified access tok
|
||||
assert.equal(refreshes, 1)
|
||||
assert.ok(calls.every((call) => call.base === '/api'))
|
||||
assert.equal(calls.find((call) => call.url === '/auth/refresh').token, undefined)
|
||||
assert.equal(calls.filter((call) => call.token === 'Bearer new-access').length, 2)
|
||||
assert.equal(calls.filter((call) => call.token === `Bearer ${newAccess}`).length, 2)
|
||||
})
|
||||
|
||||
test('successful order submit with explicit null data is accepted', async () => {
|
||||
@@ -76,3 +84,75 @@ test('data-less security failures preserve diagnostics and malformed success is
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('late refresh response cannot overwrite a session changed in another tab', async () => {
|
||||
let complete, started
|
||||
const ready = new Promise(resolve => { started = resolve })
|
||||
const response = new Promise(resolve => { complete = resolve })
|
||||
const http = harness(async config => { started(); await response; return { config, status: 200, headers: {}, data: { code: '200', message: 'OK', data: { accessToken: oldAccess, refreshToken: 'stale-refresh' } } } })
|
||||
const pending = http.refreshAuthenticatedSession()
|
||||
await ready
|
||||
http.setSession(otherAccess, 'other-refresh')
|
||||
complete()
|
||||
await pending.catch(error => assert.equal(error.code, 'SESSION_CHANGED'))
|
||||
assert.deepEqual(http.session(), { token: otherAccess, refreshToken: 'other-refresh' })
|
||||
})
|
||||
|
||||
function deferred() { let resolve; const promise = new Promise(yes => { resolve = yes }); return { promise, resolve } }
|
||||
function unauthorized(config) {
|
||||
return new axios.AxiosError('Expired', 'ERR_BAD_REQUEST', config, null, { status: 401, data: { code: 'A0401', message: 'Expired' } })
|
||||
}
|
||||
|
||||
test('late A write 401 after user, customer or membership switch never refreshes or replays under B', async () => {
|
||||
for (const next of [access(2), access(1, 2), access(1, 1, 2)]) {
|
||||
const sent = deferred(), release = deferred(), calls = []
|
||||
const http = harness(async config => {
|
||||
calls.push(config.url); sent.resolve(); await release.promise
|
||||
throw unauthorized(config)
|
||||
})
|
||||
const pending = http.apiRequest({ method: 'POST', url: '/merchant/v1/orders/first-leg/one/submit', data: { expectedVersion: 1 } })
|
||||
const rejected = assert.rejects(pending, error => error.code === 'SESSION_CHANGED')
|
||||
await sent.promise; http.setSession(next, 'refresh-b'); release.resolve(); await rejected
|
||||
assert.deepEqual(calls, ['/merchant/v1/orders/first-leg/one/submit'])
|
||||
assert.deepEqual(http.session(), { token: next, refreshToken: 'refresh-b' })
|
||||
}
|
||||
})
|
||||
|
||||
test('switch during in-flight refresh cannot reuse B token to replay an A write', async () => {
|
||||
const refreshing = deferred(), release = deferred(), calls = []
|
||||
const http = harness(async config => {
|
||||
calls.push(config.url)
|
||||
if (config.url === '/auth/refresh') {
|
||||
refreshing.resolve(); await release.promise
|
||||
return { config, status: 200, headers: {}, data: { code: '200', message: 'OK', data: { accessToken: newAccess, refreshToken: 'new-refresh' } } }
|
||||
}
|
||||
throw unauthorized(config)
|
||||
})
|
||||
const pending = http.apiRequest({ method: 'POST', url: '/merchant/v1/orders/first-leg/one/submit' })
|
||||
const rejected = assert.rejects(pending, error => error.code === 'SESSION_CHANGED')
|
||||
await refreshing.promise; http.setSession(otherAccess, 'refresh-b'); release.resolve(); await rejected
|
||||
assert.deepEqual(calls, ['/merchant/v1/orders/first-leg/one/submit', '/auth/refresh'])
|
||||
assert.deepEqual(http.session(), { token: otherAccess, refreshToken: 'refresh-b' })
|
||||
})
|
||||
|
||||
test('refresh lock wait reuses same-identity rotation but rejects changed-identity reuse', async t => {
|
||||
const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
|
||||
t.after(() => { if (originalNavigator) Object.defineProperty(globalThis, 'navigator', originalNavigator); else delete globalThis.navigator })
|
||||
for (const next of [newAccess, otherAccess]) {
|
||||
const waiting = deferred(), release = deferred(), calls = []
|
||||
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { locks: { request: async (_name, _options, run) => {
|
||||
waiting.resolve(); await release.promise; return run()
|
||||
} } } })
|
||||
const http = harness(async config => {
|
||||
calls.push({ url: config.url, authorization: config.headers.Authorization })
|
||||
if (config.headers.Authorization === `Bearer ${oldAccess}`) throw unauthorized(config)
|
||||
return { config, status: 200, headers: {}, data: { code: '200', message: 'OK', data: null } }
|
||||
})
|
||||
const pending = http.apiRequest({ method: 'POST', url: '/merchant/v1/orders/first-leg/one/submit' })
|
||||
const outcome = next === otherAccess ? assert.rejects(pending, error => error.code === 'SESSION_CHANGED') : pending
|
||||
await waiting.promise; http.setSession(next, 'rotated-refresh'); release.resolve(); await outcome
|
||||
assert.equal(calls.some(call => call.url === '/auth/refresh'), false)
|
||||
assert.equal(calls.length, next === otherAccess ? 1 : 2)
|
||||
assert.deepEqual(http.session(), { token: next, refreshToken: 'rotated-refresh' })
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user