fix(auth): revoke captured client sessions safely on logout
This commit is contained in:
@@ -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 } }
|
||||
|
||||
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) {
|
||||
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' })
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user