fix(auth): restore cross-tab permissions and bind retries to session identity

This commit is contained in:
2026-10-02 23:29:20 +08:00
parent e564353a80
commit 5f963f326e
7 changed files with 386 additions and 109 deletions
+34 -3
View File
@@ -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()
+12
View File
@@ -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
View File
@@ -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)
}
+85 -5
View File
@@ -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' })
}
})
+91 -96
View File
@@ -61,18 +61,33 @@ export const useAuthStore = defineStore('client-auth', () => {
const permissionCodes = ref<string[]>([])
const sessionRestored = ref(false)
let generation = 0
let contextRequest: { generation: number; promise: Promise<void> } | undefined
let restoreTimer: ReturnType<typeof setTimeout> | undefined
function identity(value: MerchantJwtPayload | null) {
return value ? JSON.stringify([value.user_id, value.tenant_id, value.membership_id, value.operator_tenant_id, value.app_code, value.client_type]) : ''
}
function applyToken(accessToken: string) {
const decoded = decodeMerchantToken(accessToken)
const changedIdentity = identity(decoded) !== identity(payload.value)
if (accessToken === token.value && payload.value && !changedIdentity) return
generation++
if (changedIdentity) {
user.value = null
profile.value = { ...emptyProfile }
permissionCodes.value = mapPermissions()
}
payload.value = decoded
token.value = accessToken
user.value = currentUser(decoded, user.value?.tenantName)
profile.value.username = decoded.username
profile.value.realName = decoded.username
permissionCodes.value = mapPermissions()
sessionRestored.value = false
}
function clearSession() {
clearSessionTokens()
function resetLocalSession() {
generation++
token.value = ''
payload.value = null
user.value = null
@@ -81,137 +96,113 @@ export const useAuthStore = defineStore('client-auth', () => {
sessionRestored.value = true
}
function clearSession() {
clearSessionTokens()
resetLocalSession()
}
function applyStoredSession() {
const storedToken = getAccessToken()
if (!storedToken) {
token.value = ''
payload.value = null
user.value = null
permissionCodes.value = []
profile.value = { ...emptyProfile }
sessionRestored.value = true
resetLocalSession()
return
}
try {
applyToken(storedToken)
sessionRestored.value = false
} catch {
clearSession()
}
}
if (token.value) {
try {
applyToken(token.value)
} catch (error) {
if (!(error instanceof MerchantTokenExpiredError && getRefreshToken())) clearSession()
resetLocalSession()
if (error instanceof MerchantTokenExpiredError && getRefreshToken()) sessionRestored.value = false
}
}
applyStoredSession()
const isAuthenticated = computed(() => Boolean(token.value && payload.value))
async function loadSessionContext() {
if (!payload.value) return
const currentPermissionCodes = await getCurrentMerchantPermissionCodes()
const currentAccessToken = getAccessToken()
if (currentAccessToken && currentAccessToken !== token.value) applyToken(currentAccessToken)
permissionCodes.value = mapPermissions(currentPermissionCodes)
const [tenant, personalProfile] = await Promise.all([
getTenantProfile(),
getMerchantPersonalProfile(),
])
user.value = currentUser(payload.value, tenant.tenantName)
profile.value = {
...profile.value,
realName: personalProfile.displayName || payload.value.username,
phone: personalProfile.phone || '',
email: personalProfile.email || '',
tenantName: tenant.tenantName,
}
}
async function restoreSession() {
if (sessionRestored.value) return
if (contextRequest?.generation === generation) return contextRequest.promise
let requestGeneration = generation
let requestAccessToken = getAccessToken()
const isCurrent = () => requestGeneration === generation && getAccessToken() === requestAccessToken
const promise = (async () => {
try {
if (!isAuthenticated.value) {
if (!getRefreshToken()) {
sessionRestored.value = true
return
if (!getRefreshToken()) return
const refreshedToken = await refreshAuthenticatedSession()
if (requestGeneration !== generation) return restoreSession()
applyToken(refreshedToken)
requestAccessToken = refreshedToken
requestGeneration = generation
if (contextRequest) contextRequest.generation = generation
}
try {
applyToken(await refreshAuthenticatedSession())
const requestPayload = payload.value
if (!requestPayload) return
const [codes, tenant, personalProfile] = await Promise.all([
getCurrentMerchantPermissionCodes(), getTenantProfile(), getMerchantPersonalProfile(),
])
if (!isCurrent()) {
applyStoredSession()
return restoreSession()
}
permissionCodes.value = mapPermissions(codes)
user.value = currentUser(requestPayload, tenant.tenantName)
profile.value = {
username: requestPayload.username,
realName: personalProfile.displayName || requestPayload.username,
phone: personalProfile.phone || '', email: personalProfile.email || '', tenantName: tenant.tenantName,
}
sessionRestored.value = true
} catch (error) {
if (!isCurrent()) return
sessionRestored.value = false
if (isExpiredSessionError(error)) {
clearSession()
throw error
}
sessionRestored.value = true
return
}
})()
contextRequest = { generation: requestGeneration, promise }
try { await promise } finally {
if (contextRequest?.promise === promise) contextRequest = undefined
}
}
try {
await loadSessionContext()
} catch (error) {
if (isExpiredSessionError(error)) {
clearSession()
throw error
}
sessionRestored.value = true
return
}
sessionRestored.value = true
function synchronizeSession() {
// Identity changes and logout revoke local rights immediately. Coalesce pair writes
// before loading the new context; same-identity rotation retains confirmed rights.
applyStoredSession()
if (restoreTimer) clearTimeout(restoreTimer)
restoreTimer = setTimeout(() => {
restoreTimer = undefined
void restoreSession().catch(() => {})
}, 0)
}
async function login(loginId: string, password: string, remember = false) {
const attempt = ++generation
const tokens = await loginMerchant(loginId, password)
const decoded = decodeMerchantToken(tokens.accessToken)
if (attempt !== generation) return
decodeMerchantToken(tokens.accessToken)
persistTokenPair(tokens)
persistRememberedLoginId(loginId, remember)
token.value = tokens.accessToken
payload.value = decoded
user.value = currentUser(decoded)
profile.value = { ...emptyProfile, username: decoded.username, realName: decoded.username }
permissionCodes.value = mapPermissions()
sessionRestored.value = false
try {
await loadSessionContext()
sessionRestored.value = true
} catch (error) {
if (isExpiredSessionError(error)) {
clearSession()
throw error
}
sessionRestored.value = true
}
applyToken(tokens.accessToken)
await restoreSession()
}
async function acceptImpersonationCode(code: string) {
const attempt = ++generation
const exchanged = await exchangeImpersonationCode(code)
const decoded = decodeMerchantToken(exchanged.accessToken)
if (attempt !== generation) return
decodeMerchantToken(exchanged.accessToken)
persistTemporaryAccessToken(exchanged.accessToken)
token.value = exchanged.accessToken
payload.value = decoded
user.value = currentUser(decoded)
profile.value = { ...emptyProfile, username: decoded.username, realName: decoded.username }
permissionCodes.value = mapPermissions()
sessionRestored.value = false
try {
await loadSessionContext()
} catch (error) {
if (isExpiredSessionError(error)) {
clearSession()
throw error
}
}
sessionRestored.value = true
applyToken(exchanged.accessToken)
await restoreSession()
}
async function logout() {
const refreshToken = getRefreshToken()
try {
if (refreshToken) await logoutMerchant(refreshToken)
} finally {
clearSession()
}
if (refreshToken) await logoutMerchant(refreshToken)
}
function hasPermission(permission?: string) {
@@ -224,10 +215,13 @@ export const useAuthStore = defineStore('client-auth', () => {
}
async function savePersonalProfile(next: Pick<ClientAccountProfile, 'realName' | 'email'>) {
const requestGeneration = generation
const requestToken = getAccessToken()
const updated = await updateMerchantPersonalProfile({
displayName: next.realName.trim(),
email: next.email.trim(),
})
if (requestGeneration !== generation || requestToken !== getAccessToken()) return
updateProfile({
realName: updated.displayName,
phone: updated.phone || '',
@@ -236,8 +230,9 @@ export const useAuthStore = defineStore('client-auth', () => {
}
window.addEventListener('cc-client-session-expired', clearSession)
window.addEventListener('cc-client-session-updated', synchronizeSession)
window.addEventListener('storage', (event) => {
if (event.key === 'cc-client-access-token' || event.key === 'cc-client-refresh-token') applyStoredSession()
if (event.storageArea === localStorage && (event.key === null || event.key === 'cc-client-access-token' || event.key === 'cc-client-refresh-token')) synchronizeSession()
})
return { token, user, profile, permissionCodes, isAuthenticated, sessionRestored, hasPermission, login, logout, clearSession, restoreSession, acceptImpersonationCode, updateProfile, savePersonalProfile }
+138
View File
@@ -0,0 +1,138 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import ts from 'typescript'
import * as vue from 'vue'
import * as pinia from 'pinia'
function compile(path, dependencies = {}) {
const code = ts.transpileModule(readFileSync(new URL(path, import.meta.url), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
}).outputText
const exports = {}
new Function('require', 'exports', code)(id => { assert.ok(id in dependencies, id); return dependencies[id] }, exports)
return exports
}
function storage() {
const values = new Map()
return { getItem: key => values.get(key) ?? null, setItem: (key, value) => values.set(key, value), removeItem: key => values.delete(key) }
}
function token(user = 1, tenant = 1, membership = 1, nonce = 'a') {
return `test.${Buffer.from(JSON.stringify({ user_id: user, tenant_id: tenant, membership_id: membership, username: `user-${user}`, app_code: 'MERCHANT', exp: 4102444800, nonce })).toString('base64url')}.test`
}
const flush = () => new Promise(resolve => setTimeout(resolve, 10))
function deferred() { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no }); return { promise, resolve, reject } }
function harness(t, expired = false) {
globalThis.localStorage = storage(); globalThis.sessionStorage = storage()
const listeners = new Map()
globalThis.window = { addEventListener: (name, fn) => listeners.set(name, fn) }
const session = compile('../../../api/session.ts')
const initial = expired ? `test.${Buffer.from(JSON.stringify({ user_id: 1, tenant_id: 1, membership_id: 1, username: 'user-1', app_code: 'MERCHANT', exp: 1 })).toString('base64url')}.test` : token()
session.persistTokenPair({ accessToken: initial, refreshToken: 'refresh-a' })
let reads = 0
const behavior = { permissions: async () => ['edit'], tenant: async () => ({ tenantName: 'company' }) }
class ApiError extends Error { constructor(status) { super('failed'); this.status = status } }
const module = compile('./auth.ts', {
vue, pinia, '@/api/session': session,
'@/modules/auth/api/auth': { logoutMerchant: async () => {} },
'@/modules/auth/utils/jwt': compile('../utils/jwt.ts'),
'@/api/http': { ApiError, refreshAuthenticatedSession: async () => {
session.persistTokenPair({ accessToken: token(1, 1, 1, 'refreshed'), refreshToken: 'refresh-new' })
listeners.get('cc-client-session-updated')()
return session.getAccessToken()
} },
'@/modules/tenant-management/public': {
FRONTEND_TO_MERCHANT_PERMISSION_CODES: { 'order:first-leg:edit': 'edit' },
hasMappedMerchantPermission: (_, codes) => codes.has('edit'),
getCurrentMerchantPermissionCodes: () => { reads++; return behavior.permissions() },
getTenantProfile: () => behavior.tenant(), getMerchantPersonalProfile: async () => ({}),
},
})
const context = pinia.createPinia(); pinia.setActivePinia(context)
t.after(async () => { await flush(); pinia.disposePinia(context) })
const auth = module.useAuthStore()
const event = key => listeners.get('storage')({ key, storageArea: localStorage })
function rotate(nextToken = token(1, 1, 1, 'b')) {
session.persistTokenPair({ accessToken: nextToken, refreshToken: 'refresh-b' })
event(session.ACCESS_TOKEN_KEY); event(session.REFRESH_TOKEN_KEY)
}
return { auth, session, event, rotate, behavior, listeners, ApiError, reads: () => reads }
}
test('pair storage rotation retains same-identity rights and automatically reloads once without navigation', async t => {
const h = harness(t); await h.auth.restoreSession()
h.behavior.permissions = async () => []
h.rotate()
assert.equal(h.auth.hasPermission('order:first-leg:edit'), true)
assert.equal(h.auth.sessionRestored, false)
await flush()
assert.equal(h.auth.sessionRestored, true)
assert.equal(h.auth.hasPermission('order:first-leg:edit'), false, 'new server rights replace the retained snapshot')
assert.equal(h.reads(), 2)
h.listeners.get('cc-client-session-updated')()
await flush()
assert.equal(h.reads(), 2, 'already applied token does not reload twice')
})
test('startup expired-token refresh waits for restored rights even when local rotation emits an event', async t => {
const h = harness(t, true)
await h.auth.restoreSession()
assert.equal(h.auth.isAuthenticated, true)
assert.equal(h.auth.hasPermission('order:first-leg:edit'), true)
assert.equal(h.auth.sessionRestored, true)
assert.equal(h.reads(), 1)
})
test('logout storage event immediately clears rights and late context cannot restore them', async t => {
const h = harness(t); const old = deferred(); h.behavior.permissions = () => old.promise
const pending = h.auth.restoreSession()
h.session.clearSessionTokens(); h.event(h.session.ACCESS_TOKEN_KEY)
assert.equal(h.auth.isAuthenticated, false)
assert.deepEqual(h.auth.permissionCodes, [])
old.resolve(['edit']); await pending; await flush()
assert.equal(h.auth.user, null)
assert.deepEqual(h.auth.permissionCodes, [])
})
test('account, customer and membership switches revoke rights immediately and reject stale request results', async t => {
const h = harness(t); await h.auth.restoreSession()
const old = deferred(); h.behavior.permissions = () => old.promise
h.rotate(); await flush()
h.behavior.permissions = async () => []
h.rotate(token(2, 2, 2, 'new'))
assert.equal(h.auth.hasPermission('order:first-leg:edit'), false)
assert.equal(h.auth.user.id, '2')
await flush(); old.resolve(['edit']); await flush()
assert.equal(h.auth.hasPermission('order:first-leg:edit'), false)
assert.equal(h.auth.user.id, '2')
h.behavior.permissions = async () => ['edit']; h.rotate(token(2, 3, 3, 'next')); await flush()
assert.equal(h.auth.hasPermission('order:first-leg:edit'), true)
h.rotate(token(2, 3, 4, 'membership'))
assert.equal(h.auth.hasPermission('order:first-leg:edit'), false)
})
test('context failure is not marked restored and explicit retry coalesces concurrent callers', async t => {
const h = harness(t)
h.behavior.permissions = async () => { throw new Error('offline') }
await h.auth.restoreSession()
assert.equal(h.auth.sessionRestored, false)
assert.equal(h.auth.hasPermission('order:first-leg:edit'), false)
const pending = deferred(); h.behavior.permissions = () => pending.promise
const first = h.auth.restoreSession(), second = h.auth.restoreSession()
assert.equal(h.reads(), 2)
pending.resolve(['edit']); await Promise.all([first, second])
assert.equal(h.auth.sessionRestored, true)
assert.equal(h.auth.hasPermission('order:first-leg:edit'), true)
})
test('stale unauthorized context response cannot clear a newer account', async t => {
const h = harness(t); const old = deferred(); h.behavior.permissions = () => old.promise
const pending = h.auth.restoreSession()
h.behavior.permissions = async () => []
h.rotate(token(2, 2, 2)); await flush()
old.reject(new h.ApiError(401)); await pending
assert.equal(h.auth.user.id, '2')
assert.equal(h.auth.isAuthenticated, true)
assert.equal(h.auth.sessionRestored, true)
})
+3 -2
View File
@@ -117,7 +117,8 @@ test('uses real auth endpoints and single-flight refresh handling', () => {
assert.match(httpSource, /window\.location\.replace\(`\/login\?redirect=\$\{encodeURIComponent\(redirect\)\}`\)/)
assert.match(authStoreSource, /decodeMerchantToken/)
assert.match(authStoreSource, /MerchantTokenExpiredError && getRefreshToken\(\)/)
assert.match(authStoreSource, /applyToken\(await refreshAuthenticatedSession\(\)\)/)
assert.match(authStoreSource, /const refreshedToken = await refreshAuthenticatedSession\(\)/)
assert.match(authStoreSource, /applyToken\(refreshedToken\)/)
assert.doesNotMatch(authStoreSource, /mock-client:/)
})
@@ -152,7 +153,7 @@ test('shows a forced re-login notice after login identifiers change', () => {
test('keeps a valid local session during temporary context and refresh outages', () => {
assert.match(authStoreSource, /function isExpiredSessionError/)
assert.match(authStoreSource, /if \(isExpiredSessionError\(error\)\)/)
assert.match(authStoreSource, /sessionRestored\.value = true\s+return/)
assert.match(authStoreSource, /if \(!isCurrent\(\)\) return\s+sessionRestored\.value = false/)
assert.match(httpSource, /if \(mappedError\.status === 401 \|\| mappedError\.code === 'A0401'\)/)
assert.doesNotMatch(httpSource, /catch \(refreshError\) \{\s+clearSessionTokens\(\)/)
})