fix(auth): revoke captured client sessions safely on logout

This commit is contained in:
2026-10-05 17:13:46 +08:00
parent e139c5c87b
commit 3703d0a677
8 changed files with 175 additions and 28 deletions
+49 -4
View File
@@ -14,6 +14,7 @@ const transport = axios.create({
interface RetryableRequest extends InternalAxiosRequestConfig { interface RetryableRequest extends InternalAxiosRequestConfig {
_retriedAfterRefresh?: boolean _retriedAfterRefresh?: boolean
_sessionIdentity?: string | null _sessionIdentity?: string | null
_logoutGeneration?: number
} }
function unwrap<T>(envelope: ApiEnvelope<T>) { function unwrap<T>(envelope: ApiEnvelope<T>) {
@@ -52,6 +53,8 @@ function mapError(error: unknown) {
return new ApiError(error.message || '请求失败', 'HTTP_ERROR', error.response?.status) return new ApiError(error.message || '请求失败', 'HTTP_ERROR', error.response?.status)
} }
let refreshExchange: { identity: string; refreshToken: string; promise: Promise<TokenPair> } | null = null
let logoutGeneration = 0
let refreshPromise: Promise<string> | null = null let refreshPromise: Promise<string> | null = null
let refreshIdentity: string | null = null let refreshIdentity: string | null = null
let redirectingToLogin = false let redirectingToLogin = false
@@ -95,8 +98,10 @@ export async function refreshAuthenticatedSession() {
}, },
refresh: async (currentRefreshToken) => { refresh: async (currentRefreshToken) => {
requireCurrentIdentity(observedIdentity) requireCurrentIdentity(observedIdentity)
const response = await transport.post<ApiEnvelope<TokenPair>>('/auth/refresh', { refreshToken: currentRefreshToken }) const exchange = { identity: observedIdentity!, refreshToken: currentRefreshToken, promise: transport.post<ApiEnvelope<TokenPair>>('/auth/refresh', { refreshToken: currentRefreshToken }).then(response => unwrap(response.data)) }
const tokens = unwrap(response.data) refreshExchange = exchange
let tokens: TokenPair
try { tokens = await exchange.promise } finally { if (refreshExchange === exchange) refreshExchange = null }
requireCurrentIdentity(observedIdentity) requireCurrentIdentity(observedIdentity)
requireCurrentIdentity(observedIdentity, tokens.accessToken) requireCurrentIdentity(observedIdentity, tokens.accessToken)
if (!getRefreshToken()) throw new ApiError('登录已在其他标签页退出', 'A0401', 401) if (!getRefreshToken()) throw new ApiError('登录已在其他标签页退出', 'A0401', 401)
@@ -134,29 +139,42 @@ transport.interceptors.request.use((config) => {
const request = config as RetryableRequest const request = config as RetryableRequest
if (request._retriedAfterRefresh && !isPublicAuthRequest(config.url)) requireCurrentIdentity(request._sessionIdentity, token) if (request._retriedAfterRefresh && !isPublicAuthRequest(config.url)) requireCurrentIdentity(request._sessionIdentity, token)
if (token && !isPublicAuthRequest(config.url)) { if (token && !isPublicAuthRequest(config.url)) {
if (!request._retriedAfterRefresh) request._sessionIdentity = accessTokenIdentity(token) if (!request._retriedAfterRefresh) {
request._sessionIdentity = accessTokenIdentity(token)
request._logoutGeneration = logoutGeneration
}
config.headers.Authorization = `Bearer ${token}` config.headers.Authorization = `Bearer ${token}`
} }
return config return config
}) })
transport.interceptors.response.use( transport.interceptors.response.use(
(response) => response, (response) => {
const request = response.config as RetryableRequest
if (request._sessionIdentity) {
if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED')
requireCurrentIdentity(request._sessionIdentity)
}
return response
},
async (error: AxiosError) => { async (error: AxiosError) => {
const request = error.config as RetryableRequest | undefined const request = error.config as RetryableRequest | undefined
if (error.response?.status !== 401 || !request || request._retriedAfterRefresh || isPublicAuthRequest(request.url)) { if (error.response?.status !== 401 || !request || request._retriedAfterRefresh || isPublicAuthRequest(request.url)) {
throw mapError(error) throw mapError(error)
} }
if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED')
requireCurrentIdentity(request._sessionIdentity) requireCurrentIdentity(request._sessionIdentity)
request._retriedAfterRefresh = true request._retriedAfterRefresh = true
const refreshTokenBeforeRetry = getRefreshToken() const refreshTokenBeforeRetry = getRefreshToken()
try { try {
const accessToken = await refreshAuthenticatedSession() const accessToken = await refreshAuthenticatedSession()
if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED')
requireCurrentIdentity(request._sessionIdentity) requireCurrentIdentity(request._sessionIdentity)
request.headers.Authorization = `Bearer ${accessToken}` request.headers.Authorization = `Bearer ${accessToken}`
return await transport.request(request) return await transport.request(request)
} catch (refreshError) { } catch (refreshError) {
if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED')
requireCurrentIdentity(request._sessionIdentity) requireCurrentIdentity(request._sessionIdentity)
const mappedError = mapError(refreshError) const mappedError = mapError(refreshError)
if (mappedError.status === 401 || mappedError.code === 'A0401') { if (mappedError.status === 401 || mappedError.code === 'A0401') {
@@ -170,6 +188,33 @@ transport.interceptors.response.use(
}, },
) )
// Captured revocation never persists credentials or restores local login state.
export async function revokeCapturedSession(accessToken: string, refreshToken: string) {
logoutGeneration++
const identity = accessTokenIdentity(accessToken)
if (!identity || !refreshToken) throw new ApiError('服务器会话注销未确认', 'SESSION_CHANGED')
const pending = refreshExchange?.identity === identity && refreshExchange.refreshToken === refreshToken ? refreshExchange.promise : null
const revoke = (pair: Pick<TokenPair, 'accessToken' | 'refreshToken'>) => apiRequest<void>({
method: 'POST', url: '/auth/logout', data: { refreshToken: pair.refreshToken },
headers: { Authorization: `Bearer ${pair.accessToken}` },
})
const validate = (pair: TokenPair) => {
if (accessTokenIdentity(pair.accessToken) !== identity || !pair.refreshToken) throw new ApiError('刷新身份不一致,服务器会话注销未确认', 'SESSION_CHANGED')
return pair
}
// An already sent refresh can rotate the pair. Consume its actual result
// without issuing a competing refresh or restoring it to local storage.
if (pending) {
await revoke(validate(await pending))
return
}
try { await revoke({ accessToken, refreshToken }) } catch (error) {
if (!(error instanceof ApiError) || error.status !== 401) throw error
const pair = await apiRequest<TokenPair>({ method: 'POST', url: '/auth/refresh', data: { refreshToken } })
await revoke(validate(pair))
}
}
export async function apiRequest<T>(config: AxiosRequestConfig) { export async function apiRequest<T>(config: AxiosRequestConfig) {
try { try {
const response = await transport.request<ApiEnvelope<T>>(config) const response = await transport.request<ApiEnvelope<T>>(config)
+20
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import { import {
accessTokenIdentity,
ACCESS_TOKEN_KEY, ACCESS_TOKEN_KEY,
REFRESH_TOKEN_KEY, REFRESH_TOKEN_KEY,
REMEMBERED_LOGIN_ID_KEY, REMEMBERED_LOGIN_ID_KEY,
@@ -68,3 +69,22 @@ test('normal token rotation never removes either shared token between writes', (
assert.ok(!removed.includes(ACCESS_TOKEN_KEY)) assert.ok(!removed.includes(ACCESS_TOKEN_KEY))
assert.ok(!removed.includes(REFRESH_TOKEN_KEY)) assert.ok(!removed.includes(REFRESH_TOKEN_KEY))
}) })
function rawIdentity(overrides = '') {
return `test.${Buffer.from(`{"user_id":2107003446494334978,"tenant_id":2107003446494334978,"membership_id":2107003446494334978,"app_code":"MERCHANT"${overrides}}`).toString('base64url')}.test`
}
test('identity preserves low digits of numeric 19 digit user customer and membership',()=>{
const original=rawIdentity();const json=Buffer.from(original.split('.')[1],'base64url').toString();
for(const field of ['user_id','tenant_id','membership_id']){
const changed=json.replace(`"${field}":2107003446494334978`,`"${field}":2107003446494334979`)
assert.notEqual(accessTokenIdentity(original),accessTokenIdentity(`test.${Buffer.from(changed).toString('base64url')}.test`))
}
})
test('identity string and numeric integers are equivalent without changing other strings',()=>{
const a=rawIdentity();const raw=Buffer.from(a.split('.')[1],'base64url').toString();
const b=`test.${Buffer.from(raw.replaceAll(':2107003446494334978',':"2107003446494334978"')).toString('base64url')}.test`
assert.equal(accessTokenIdentity(a),accessTokenIdentity(b))
const decorated=rawIdentity(',"username":'+JSON.stringify('number 2107003446494334979 and "tenant_id":2107003446494334979')+',"note":"1234567890123456789"')
assert.equal(accessTokenIdentity(a),accessTokenIdentity(decorated))
assert.ok(accessTokenIdentity(a).includes('2107003446494334978'))
})
+10 -1
View File
@@ -27,7 +27,16 @@ export function accessTokenIdentity(token: string): string | null {
if (!encoded) return null if (!encoded) return null
const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/') 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 bytes = Uint8Array.from(atob(normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')), character => character.charCodeAt(0))
const value = JSON.parse(new TextDecoder().decode(bytes)) const json = new TextDecoder().decode(bytes)
// Match whole JSON strings first so digits inside arbitrary string values
// are never rewritten. Preserve integer identity claims before JSON.parse
// can round them; number/string representations then share one identity.
const exactIdentityJson = json.replace(/"(?:\\.|[^"\\])*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g, (part, offset: number) => {
if (!/^-?\d+$/.test(part)) return part
const key = json.slice(0, offset).match(/"(user_id|tenant_id|membership_id|operator_tenant_id)"\s*:\s*$/)
return key ? JSON.stringify(part) : part
})
const value = JSON.parse(exactIdentityJson)
if (!value?.user_id || !value.tenant_id || !value.membership_id || value.app_code !== 'MERCHANT') return null 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))) 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 { } catch {
+63 -16
View File
@@ -100,22 +100,6 @@ test('late refresh response cannot overwrite a session changed in another tab',
function deferred() { let resolve; const promise = new Promise(yes => { resolve = yes }); return { promise, resolve } } function deferred() { let resolve; const promise = new Promise(yes => { resolve = yes }); return { promise, resolve } }
test('logout after local clear sends only the original refresh token and never refreshes or clears a later login', async () => {
const sent = deferred(), release = deferred(), calls = []
const http = harness(async config => {
calls.push(config); sent.resolve(); await release.promise; throw unauthorized(config)
})
http.setSession('', '')
const api = compile('../modules/auth/api/auth.ts', { '@/api/http': http })
const pending = api.logoutMerchant('old-refresh')
const rejected = assert.rejects(pending, error => error.code === 'A0401')
await sent.promise; http.setSession(otherAccess, 'refresh-b'); release.resolve(); await rejected
assert.equal(calls.length, 1)
assert.equal(calls[0].url, '/auth/logout')
assert.equal(calls[0].headers.Authorization, undefined)
assert.deepEqual(JSON.parse(calls[0].data), { refreshToken: 'old-refresh' })
assert.deepEqual(http.session(), { token: otherAccess, refreshToken: 'refresh-b' })
})
function unauthorized(config) { function unauthorized(config) {
return new axios.AxiosError('Expired', 'ERR_BAD_REQUEST', config, null, { status: 401, data: { code: 'A0401', message: 'Expired' } }) return new axios.AxiosError('Expired', 'ERR_BAD_REQUEST', config, null, { status: 401, data: { code: 'A0401', message: 'Expired' } })
} }
@@ -173,3 +157,66 @@ test('refresh lock wait reuses same-identity rotation but rejects changed-identi
assert.deepEqual(http.session(), { token: next, refreshToken: 'rotated-refresh' }) assert.deepEqual(http.session(), { token: next, refreshToken: 'rotated-refresh' })
} }
}) })
function authApi(http) { return compile('../modules/auth/api/auth.ts', { '@/api/http': http }) }
function ok(config, data = null) { return { config, status: 200, headers: {}, data: { code: '200', message: 'OK', data } } }
test('logout after immediate local clear revokes captured Bearer and Refresh', async () => {
const calls=[]; const http=harness(async c=>{calls.push(c);return ok(c)})
http.setSession('', '');await authApi(http).logoutMerchant(oldAccess,'old-refresh')
assert.equal(calls.length,1);assert.equal(calls[0].headers.Authorization,`Bearer ${oldAccess}`)
assert.deepEqual(JSON.parse(calls[0].data),{refreshToken:'old-refresh'});assert.deepEqual(http.session(),{token:'',refreshToken:''})
})
test('only explicit logout401 refreshes once and revokes same-identity replacement without storing it', async () => {
const calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh')return ok(c,{accessToken:newAccess,refreshToken:'new-refresh'});if(c.headers.Authorization===`Bearer ${oldAccess}`)throw unauthorized(c);return ok(c)})
http.setSession('','');await authApi(http).logoutMerchant(oldAccess,'old-refresh')
assert.deepEqual(calls.map(x=>x.url),['/auth/logout','/auth/refresh','/auth/logout']);assert.equal(calls[2].headers.Authorization,`Bearer ${newAccess}`);assert.equal(JSON.parse(calls[2].data).refreshToken,'new-refresh');assert.deepEqual(http.session(),{token:'',refreshToken:''})
})
test('unknown logout failure never refreshes; changed identity cannot be revoked as replacement', async () => {
for(const mode of ['network','wrongIdentity','second401']){const calls=[];const http=harness(async c=>{calls.push(c);if(mode==='network')throw new axios.AxiosError('offline',axios.AxiosError.ERR_NETWORK,c);if(c.url==='/auth/refresh')return ok(c,{accessToken:mode==='wrongIdentity'?otherAccess:newAccess,refreshToken:'replacement'});throw unauthorized(c)})
http.setSession('','');await assert.rejects(authApi(http).logoutMerchant(oldAccess,'old-refresh'));assert.equal(calls.length,mode==='network'?1:mode==='wrongIdentity'?2:3);assert.deepEqual(http.session(),{token:'',refreshToken:''})}
})
test('logout consumes already inflight refresh once while old business caller cannot revive session',async()=>{
const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh'){started.resolve();await release.promise;return ok(c,{accessToken:newAccess,refreshToken:'new-refresh'})}return ok(c)})
const refreshing=http.refreshAuthenticatedSession();const rejected=assert.rejects(refreshing,e=>e.code==='SESSION_CHANGED');await started.promise
const leaving=authApi(http).logoutMerchant(oldAccess,'old-refresh');http.setSession('','');release.resolve();await leaving;await rejected
assert.deepEqual(calls.map(x=>x.url),['/auth/refresh','/auth/logout']);assert.equal(calls[1].headers.Authorization,`Bearer ${newAccess}`);assert.deepEqual(http.session(),{token:'',refreshToken:''})
})
test('late successful business response after logout rejects even after same-identity new login',async()=>{
const started=deferred(),release=deferred();const http=harness(async c=>{if(c.url==='/merchant/v1/orders'){started.resolve();await release.promise}return ok(c,'old-data')})
const pending=http.apiRequest({url:'/merchant/v1/orders'});const rejected=assert.rejects(pending,e=>e.code==='SESSION_CHANGED');await started.promise
await authApi(http).logoutMerchant(oldAccess,'old-refresh');http.setSession(newAccess,'new-login-refresh');release.resolve();await rejected;assert.equal(http.session().refreshToken,'new-login-refresh')
})
test('inflight refresh unknown outcome stops logout without competing retry and preserves later login',async()=>{
const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);started.resolve();await release.promise;throw new axios.AxiosError('offline',axios.AxiosError.ERR_NETWORK,c)})
const refreshing=http.refreshAuthenticatedSession();const rejected=assert.rejects(refreshing);await started.promise
const leaving=authApi(http).logoutMerchant(oldAccess,'old-refresh');const failure=assert.rejects(leaving,e=>e.code==='NETWORK_ERROR');http.setSession(otherAccess,'other-refresh');release.resolve();await failure;await rejected
assert.equal(calls.length,1);assert.deepEqual(http.session(),{token:otherAccess,refreshToken:'other-refresh'})
})
test('logout401 refresh and replacement revocation cannot overwrite later login',async()=>{
const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh'){started.resolve();await release.promise;return ok(c,{accessToken:newAccess,refreshToken:'new-refresh'})}if(c.headers.Authorization===`Bearer ${oldAccess}`)throw unauthorized(c);return ok(c)})
const leaving=authApi(http).logoutMerchant(oldAccess,'old-refresh');http.setSession('','');await started.promise;http.setSession(otherAccess,'other-refresh');release.resolve();await leaving
assert.equal(calls[2].headers.Authorization,`Bearer ${newAccess}`);assert.deepEqual(http.session(),{token:otherAccess,refreshToken:'other-refresh'})
})
test('late old business401 after logout does not refresh or replay under same-identity new login',async()=>{
const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/merchant/v1/orders/first-leg/one/submit'){started.resolve();await release.promise;throw unauthorized(c)}return ok(c)})
const request=http.apiRequest({method:'POST',url:'/merchant/v1/orders/first-leg/one/submit'});const rejected=assert.rejects(request,e=>e.code==='SESSION_CHANGED');await started.promise;await authApi(http).logoutMerchant(oldAccess,'old-refresh');http.setSession(newAccess,'new-login-refresh');release.resolve();await rejected
assert.deepEqual(calls.map(x=>x.url),['/merchant/v1/orders/first-leg/one/submit','/auth/logout']);assert.equal(http.session().refreshToken,'new-login-refresh')
})
test('same identity new pair logout does not adopt another session inflight refresh',async()=>{
const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh'){started.resolve();await release.promise;return ok(c,{accessToken:newAccess,refreshToken:'old-session-rotated'})}return ok(c)})
const refreshing=http.refreshAuthenticatedSession();const rejected=assert.rejects(refreshing);await started.promise;http.setSession(newAccess,'new-login-refresh')
const leaving=authApi(http).logoutMerchant(newAccess,'new-login-refresh');http.setSession('','');await leaving
assert.equal(calls[1].headers.Authorization,`Bearer ${newAccess}`);assert.equal(JSON.parse(calls[1].data).refreshToken,'new-login-refresh');release.resolve();await rejected;assert.deepEqual(http.session(),{token:'',refreshToken:''})
})
test('logout rejects refreshed 19 digit identity differing only in low digit',async()=>{
const raw='{"user_id":2107003446494334978,"tenant_id":2107003446494334978,"membership_id":2107003446494334978,"app_code":"MERCHANT"}'
const original=`test.${Buffer.from(raw).toString('base64url')}.test`
for(const field of ['user_id','tenant_id','membership_id']){
const changed=raw.replace(`"${field}":2107003446494334978`,`"${field}":2107003446494334979`),calls=[]
const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh')return ok(c,{accessToken:`test.${Buffer.from(changed).toString('base64url')}.test`,refreshToken:'replacement'});throw unauthorized(c)})
http.setSession('','');await assert.rejects(authApi(http).logoutMerchant(original,'original-refresh'),e=>e.code==='SESSION_CHANGED');assert.equal(calls.length,2)
}
})
+3 -3
View File
@@ -1,4 +1,4 @@
import { apiRequest } from '@/api/http' import { apiRequest, revokeCapturedSession } from '@/api/http'
import type { TokenPair } from '@/api/session' import type { TokenPair } from '@/api/session'
export interface RegisterMerchantRequest { export interface RegisterMerchantRequest {
@@ -35,8 +35,8 @@ export function registerMerchant(payload: RegisterMerchantRequest) {
return apiRequest<RegisterMerchantResult>({ method: 'POST', url: '/auth/register', data: payload }) return apiRequest<RegisterMerchantResult>({ method: 'POST', url: '/auth/register', data: payload })
} }
export function logoutMerchant(refreshToken: string) { export function logoutMerchant(accessToken: string, refreshToken: string) {
return apiRequest<void>({ method: 'POST', url: '/auth/logout', data: { refreshToken } }) return revokeCapturedSession(accessToken, refreshToken)
} }
export function changeCurrentPassword(oldPassword: string, newPassword: string) { export function changeCurrentPassword(oldPassword: string, newPassword: string) {
+13 -2
View File
@@ -61,6 +61,7 @@ export const useAuthStore = defineStore('client-auth', () => {
const permissionCodes = ref<string[]>([]) const permissionCodes = ref<string[]>([])
const sessionRestored = ref(false) const sessionRestored = ref(false)
let logoutRequest: { accessToken: string; refreshToken: string; promise: Promise<void> } | undefined
let generation = 0 let generation = 0
let contextRequest: { generation: number; promise: Promise<void> } | undefined let contextRequest: { generation: number; promise: Promise<void> } | undefined
let restoreTimer: ReturnType<typeof setTimeout> | undefined let restoreTimer: ReturnType<typeof setTimeout> | undefined
@@ -199,10 +200,20 @@ export const useAuthStore = defineStore('client-auth', () => {
await restoreSession() await restoreSession()
} }
async function logout() { function logout() {
const accessToken = getAccessToken()
const refreshToken = getRefreshToken() const refreshToken = getRefreshToken()
if (logoutRequest && ((!accessToken && !refreshToken)
|| (logoutRequest.accessToken === accessToken && logoutRequest.refreshToken === refreshToken))) return logoutRequest.promise
const pending = accessToken || refreshToken
? logoutMerchant(accessToken, refreshToken)
: Promise.resolve()
clearSession() clearSession()
if (refreshToken) await logoutMerchant(refreshToken) const request = { accessToken, refreshToken, promise: pending.finally(() => {
if (logoutRequest === request) logoutRequest = undefined
}) }
logoutRequest = request
return request.promise
} }
function hasPermission(permission?: string) { function hasPermission(permission?: string) {
+15 -1
View File
@@ -35,7 +35,7 @@ function harness(t, expired = false) {
class ApiError extends Error { constructor(status) { super('failed'); this.status = status } } class ApiError extends Error { constructor(status) { super('failed'); this.status = status } }
const module = compile('./auth.ts', { const module = compile('./auth.ts', {
vue, pinia, '@/api/session': session, vue, pinia, '@/api/session': session,
'@/modules/auth/api/auth': { logoutMerchant: async refresh => behavior.logout?.(refresh) }, '@/modules/auth/api/auth': { logoutMerchant: async (access, refresh) => behavior.logout?.(refresh, access) },
'@/modules/auth/utils/jwt': compile('../utils/jwt.ts'), '@/modules/auth/utils/jwt': compile('../utils/jwt.ts'),
'@/api/http': { ApiError, refreshAuthenticatedSession: async () => { '@/api/http': { ApiError, refreshAuthenticatedSession: async () => {
session.persistTokenPair({ accessToken: token(1, 1, 1, 'refreshed'), refreshToken: 'refresh-new' }) session.persistTokenPair({ accessToken: token(1, 1, 1, 'refreshed'), refreshToken: 'refresh-new' })
@@ -152,3 +152,17 @@ test('store logout clears immediately and late revocation failure preserves a la
assert.equal(h.auth.isAuthenticated, true) assert.equal(h.auth.isAuthenticated, true)
assert.equal(h.session.getRefreshToken(), 'refresh-b') assert.equal(h.session.getRefreshToken(), 'refresh-b')
}) })
test('duplicate logout joins captured pair revocation and does not send again',async t=>{
const h=harness(t);const remote=deferred();let count=0,sent
h.behavior.logout=(refresh,access)=>{count++;sent={refresh,access};return remote.promise}
const a=h.auth.logout(),b=h.auth.logout();assert.equal(h.auth.isAuthenticated,false);assert.equal(sent.access,token());assert.equal(sent.refresh,'refresh-a');assert.equal(count,1);remote.resolve();await Promise.all([a,b])
})
test('B logout during A pending revokes B; A finally cannot release B deduplication',async t=>{
const h=harness(t);const a=deferred(),b=deferred(),calls=[]
h.behavior.logout=(refresh,access)=>{calls.push({refresh,access});return calls.length===1?a.promise:b.promise}
const leavingA=h.auth.logout();h.rotate(token(2,2,2));await flush();assert.equal(h.auth.isAuthenticated,true)
const leavingB=h.auth.logout();assert.equal(h.auth.isAuthenticated,false);assert.equal(h.session.getAccessToken(),'');assert.equal(calls.length,2);assert.equal(calls[1].access,token(2,2,2))
a.resolve();await leavingA;const duplicateB=h.auth.logout();assert.equal(calls.length,2);let duplicateFinished=false;void duplicateB.then(()=>{duplicateFinished=true});await flush();assert.equal(duplicateFinished,false);b.resolve();await Promise.all([leavingB,duplicateB])
})
+2 -1
View File
@@ -102,7 +102,8 @@ test('opens complete service and privacy documents from registration', () => {
test('uses real auth endpoints and single-flight refresh handling', () => { test('uses real auth endpoints and single-flight refresh handling', () => {
assert.match(authApiSource, /\/auth\/login/) assert.match(authApiSource, /\/auth\/login/)
assert.match(authApiSource, /\/auth\/register/) assert.match(authApiSource, /\/auth\/register/)
assert.match(authApiSource, /\/auth\/logout/) assert.match(authApiSource, /revokeCapturedSession\(accessToken, refreshToken\)/)
assert.match(httpSource, /\/auth\/logout/)
assert.match(httpSource, /refreshPromise/) assert.match(httpSource, /refreshPromise/)
assert.match(httpSource, /\/auth\/refresh/) assert.match(httpSource, /\/auth\/refresh/)
assert.match(httpSource, /persistTokenPair\(tokens\)/) assert.match(httpSource, /persistTokenPair\(tokens\)/)