fix(auth): restore cross-tab permissions and bind retries to session identity
This commit is contained in:
@@ -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 (!isAuthenticated.value) {
|
||||
if (!getRefreshToken()) {
|
||||
sessionRestored.value = true
|
||||
return
|
||||
}
|
||||
if (contextRequest?.generation === generation) return contextRequest.promise
|
||||
let requestGeneration = generation
|
||||
let requestAccessToken = getAccessToken()
|
||||
const isCurrent = () => requestGeneration === generation && getAccessToken() === requestAccessToken
|
||||
const promise = (async () => {
|
||||
try {
|
||||
applyToken(await refreshAuthenticatedSession())
|
||||
if (!isAuthenticated.value) {
|
||||
if (!getRefreshToken()) return
|
||||
const refreshedToken = await refreshAuthenticatedSession()
|
||||
if (requestGeneration !== generation) return restoreSession()
|
||||
applyToken(refreshedToken)
|
||||
requestAccessToken = refreshedToken
|
||||
requestGeneration = generation
|
||||
if (contextRequest) contextRequest.generation = generation
|
||||
}
|
||||
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()
|
||||
}
|
||||
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 }
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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\(\)/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user