From 5f963f326ef3179fbbe2b7373f31e78162e1b7c0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 2 Oct 2026 23:29:20 +0800 Subject: [PATCH] fix(auth): restore cross-tab permissions and bind retries to session identity --- src/api/http.ts | 37 +++- src/api/session.test.mjs | 12 ++ src/api/session.ts | 22 ++- src/api/unifiedHttp.test.mjs | 90 ++++++++- src/modules/auth/store/auth.ts | 191 ++++++++++---------- src/modules/auth/store/authSession.test.mjs | 138 ++++++++++++++ src/modules/auth/views/AuthViews.test.mjs | 5 +- 7 files changed, 386 insertions(+), 109 deletions(-) create mode 100644 src/modules/auth/store/authSession.test.mjs diff --git a/src/api/http.ts b/src/api/http.ts index eb0b66e..8b29363 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -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(envelope: ApiEnvelope) { @@ -52,6 +53,7 @@ function mapError(error: unknown) { } let refreshPromise: Promise | 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>('/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() diff --git a/src/api/session.test.mjs b/src/api/session.test.mjs index ce48286..c2916d2 100644 --- a/src/api/session.test.mjs +++ b/src/api/session.test.mjs @@ -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)) +}) diff --git a/src/api/session.ts b/src/api/session.ts index dbdcdd1..cda260b 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -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) } diff --git a/src/api/unifiedHttp.test.mjs b/src/api/unifiedHttp.test.mjs index ecc5ba3..2221c59 100644 --- a/src/api/unifiedHttp.test.mjs +++ b/src/api/unifiedHttp.test.mjs @@ -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' }) + } +}) diff --git a/src/modules/auth/store/auth.ts b/src/modules/auth/store/auth.ts index 9c1a903..1997e8d 100644 --- a/src/modules/auth/store/auth.ts +++ b/src/modules/auth/store/auth.ts @@ -61,18 +61,33 @@ export const useAuthStore = defineStore('client-auth', () => { const permissionCodes = ref([]) const sessionRestored = ref(false) + let generation = 0 + let contextRequest: { generation: number; promise: Promise } | undefined + let restoreTimer: ReturnType | 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) { + 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 } diff --git a/src/modules/auth/store/authSession.test.mjs b/src/modules/auth/store/authSession.test.mjs new file mode 100644 index 0000000..37ef6e3 --- /dev/null +++ b/src/modules/auth/store/authSession.test.mjs @@ -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) +}) diff --git a/src/modules/auth/views/AuthViews.test.mjs b/src/modules/auth/views/AuthViews.test.mjs index dafdd34..0b2fc45 100644 --- a/src/modules/auth/views/AuthViews.test.mjs +++ b/src/modules/auth/views/AuthViews.test.mjs @@ -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\(\)/) })