fix(auth): restore cross-tab permissions and bind retries to session identity
This commit is contained in:
@@ -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' })
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user