From c04ef14f28da0cc4116cda34c41e263da17e3bef Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 11:54:00 +0100 Subject: [PATCH 1/3] test(http): pin request-owned session boundaries --- .../tests/api/httpSessionOwnership.spec.ts | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/api/httpSessionOwnership.spec.ts diff --git a/frontend/taskdeck-web/src/tests/api/httpSessionOwnership.spec.ts b/frontend/taskdeck-web/src/tests/api/httpSessionOwnership.spec.ts new file mode 100644 index 000000000..1d125086f --- /dev/null +++ b/frontend/taskdeck-web/src/tests/api/httpSessionOwnership.spec.ts @@ -0,0 +1,175 @@ +/** Real Axios interceptor regressions for request-owned credentials (#3317). */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import axios, { AxiosHeaders } from 'axios' +import MockAdapter from 'axios-mock-adapter' + +const effects = vi.hoisted(() => ({ purge: vi.fn(), expired: vi.fn() })) +vi.mock('../../utils/demoMode', () => ({ isDemoMode: false })) +vi.mock('../../utils/errorReporting', () => ({ logError: vi.fn(), logWarn: vi.fn() })) +vi.mock('../../utils/authExpiry', () => ({ notifyAuthExpired: effects.expired })) +vi.mock('../../pwa/legacyApiCache', () => ({ purgeLegacyApiCaches: effects.purge })) + +import http from '../../api/http' +import * as tokenStorage from '../../utils/tokenStorage' + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { resolve = done }) + return { promise, resolve } +} + +function jwt(subject: string, seconds = 3600): string { + const encode = (value: object) => btoa(JSON.stringify(value)) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '') + return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode({ + sub: subject, exp: Math.floor(Date.now() / 1000) + seconds, + })}.synthetic` +} + +function signIn(subject: string, token = jwt(subject)): string { + expect(tokenStorage.setToken(token)).toBe(true) + expect(tokenStorage.setSession({ userId: subject, username: subject, email: `${subject}@example.test` })).toBe(true) + return token +} + +describe('HTTP session ownership', () => { + let mock: MockAdapter + const originalLocation = window.location + + beforeEach(() => { + vi.useFakeTimers() + tokenStorage.clearAll() + effects.purge.mockReset().mockResolvedValue(true) + effects.expired.mockReset() + mock = new MockAdapter(http) + Object.defineProperty(window, 'location', { + value: { ...originalLocation, href: 'http://localhost/workspace/home', pathname: '/workspace/home', search: '' }, + configurable: true, + }) + }) + + afterEach(() => { + mock.restore() + vi.useRealTimers() + Object.defineProperty(window, 'location', { value: originalLocation, configurable: true }) + tokenStorage.clearAll() + }) + + function pendingReply() { + const started = deferred() + const reply = deferred<[number, object]>() + mock.onAny('/session-owned').replyOnce(() => { + started.resolve() + return reply.promise + }) + return { started, reply } + } + + it.each(['different-token', 'same-token', 'anonymous', 'external-storage'] as const)( + 'a late %s 401 cannot expire a replacement session', async (kind) => { + const firstToken = jwt('first') + if (kind !== 'anonymous') signIn('first', firstToken) + const { started, reply } = pendingReply() + const outcome = http.get('/session-owned', { skipRetry: true }).catch((error: unknown) => error) + await started.promise + + let currentToken: string + if (kind === 'external-storage') { + currentToken = jwt('second') + localStorage.setItem('taskdeck_token', currentToken) + localStorage.setItem('taskdeck_session', JSON.stringify({ + userId: 'second', username: 'second', email: 'second@example.test', + })) + } else { + tokenStorage.clearAll() + currentToken = signIn('second', kind === 'same-token' ? firstToken : jwt('second')) + } + reply.resolve([401, {}]) + expect(await outcome).toMatchObject({ response: { status: 401 } }) + expect(tokenStorage.getToken()).toBe(currentToken) + expect(tokenStorage.getSession()?.userId).toBe('second') + expect(effects.purge).not.toHaveBeenCalled() + expect(effects.expired).not.toHaveBeenCalled() + expect(window.location.href).toBe('http://localhost/workspace/home') + }, + ) + + it('still expires and redirects a request belonging to the current session', async () => { + signIn('first') + mock.onGet('/session-owned').reply(401, {}) + await expect(http.get('/session-owned')).rejects.toMatchObject({ response: { status: 401 } }) + expect(tokenStorage.getToken()).toBeNull() + expect(tokenStorage.getSession()).toBeNull() + expect(effects.purge).toHaveBeenCalledOnce() + expect(effects.expired).toHaveBeenCalledOnce() + expect(window.location.href).toBe('/login?redirect=%2Fworkspace%2Fhome') + }) + + describe.each(['get', 'put', 'delete'] as const)('%s retries', (method) => { + it.each(['before-response', 'during-backoff'] as const)( + 'never dispatch with replacement credentials after a change %s', async (stage) => { + signIn('first') + const { started, reply } = pendingReply() + mock.onAny('/session-owned').reply(200, { ok: true }) + const outcome = http.request({ url: '/session-owned', method }).catch((error: unknown) => error) + await started.promise + + if (stage === 'during-backoff') { + reply.resolve([503, {}]) + await vi.advanceTimersByTimeAsync(0) + expect(vi.getTimerCount()).toBeGreaterThan(0) + } + const currentToken = signIn('second') + if (stage === 'before-response') reply.resolve([503, {}]) + await vi.runAllTimersAsync() + + expect(axios.isCancel(await outcome)).toBe(true) + expect(mock.history[method]).toHaveLength(1) + expect(tokenStorage.getToken()).toBe(currentToken) + expect(effects.expired).not.toHaveBeenCalled() + }, + ) + }) + + it.each(['logout', 'same-token-login'] as const)( + 'invalidates a pending retry on %s even without a different token string', async (change) => { + const token = signIn('first') + mock.onGet('/session-owned').replyOnce(503, {}) + mock.onGet('/session-owned').reply(200, {}) + const outcome = http.get('/session-owned').catch((error: unknown) => error) + await vi.advanceTimersByTimeAsync(0) + expect(vi.getTimerCount()).toBeGreaterThan(0) + tokenStorage.clearAll() + if (change === 'same-token-login') signIn('first', token) + await vi.runAllTimersAsync() + expect(axios.isCancel(await outcome)).toBe(true) + expect(mock.history.get).toHaveLength(1) + expect(tokenStorage.getToken()).toBe(change === 'logout' ? null : token) + }, + ) + + it('preserves same-session retries, bearer identity and request id', async () => { + const token = signIn('first') + mock.onGet('/session-owned').replyOnce(503, {}) + mock.onGet('/session-owned').reply(200, { ok: true }) + const request = http.get('/session-owned') + await vi.runAllTimersAsync() + expect((await request).data).toEqual({ ok: true }) + expect(mock.history.get).toHaveLength(2) + const first = AxiosHeaders.from(mock.history.get[0]!.headers) + const second = AxiosHeaders.from(mock.history.get[1]!.headers) + expect(second.get('Authorization')).toBe(`Bearer ${token}`) + expect(second.get('X-Request-Id')).toBe(first.get('X-Request-Id')) + expect(effects.expired).not.toHaveBeenCalled() + }) + + it.each(['absent', 'expired'] as const)( + 'removes inherited Authorization when the stored credential is %s', async (state) => { + if (state === 'expired') tokenStorage.setToken(jwt('expired', -60)) + mock.onGet('/session-owned').reply(200, {}) + await http.get('/session-owned', { headers: { Authorization: 'Bearer obsolete' } }) + expect(AxiosHeaders.from(mock.history.get[0]!.headers).get('Authorization')).toBeUndefined() + expect(tokenStorage.getToken()).toBeNull() + }, + ) +}) From e9cde363abb650d64981ff4401ef1226c50a378f Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 11:58:22 +0100 Subject: [PATCH 2/3] test(http): narrow adapter history headers without unsafe casts --- .../src/tests/api/httpSessionOwnership.spec.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/taskdeck-web/src/tests/api/httpSessionOwnership.spec.ts b/frontend/taskdeck-web/src/tests/api/httpSessionOwnership.spec.ts index 1d125086f..6cf52e6f3 100644 --- a/frontend/taskdeck-web/src/tests/api/httpSessionOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/api/httpSessionOwnership.spec.ts @@ -12,6 +12,11 @@ vi.mock('../../pwa/legacyApiCache', () => ({ purgeLegacyApiCaches: effects.purge import http from '../../api/http' import * as tokenStorage from '../../utils/tokenStorage' +function normalizedHeaders(value: unknown): AxiosHeaders { + if (!(value instanceof AxiosHeaders)) throw new Error('Expected normalized Axios request headers') + return value +} + function deferred() { let resolve!: (value: T) => void const promise = new Promise((done) => { resolve = done }) @@ -156,8 +161,8 @@ describe('HTTP session ownership', () => { await vi.runAllTimersAsync() expect((await request).data).toEqual({ ok: true }) expect(mock.history.get).toHaveLength(2) - const first = AxiosHeaders.from(mock.history.get[0]!.headers) - const second = AxiosHeaders.from(mock.history.get[1]!.headers) + const first = normalizedHeaders(mock.history.get[0]!.headers) + const second = normalizedHeaders(mock.history.get[1]!.headers) expect(second.get('Authorization')).toBe(`Bearer ${token}`) expect(second.get('X-Request-Id')).toBe(first.get('X-Request-Id')) expect(effects.expired).not.toHaveBeenCalled() @@ -168,7 +173,7 @@ describe('HTTP session ownership', () => { if (state === 'expired') tokenStorage.setToken(jwt('expired', -60)) mock.onGet('/session-owned').reply(200, {}) await http.get('/session-owned', { headers: { Authorization: 'Bearer obsolete' } }) - expect(AxiosHeaders.from(mock.history.get[0]!.headers).get('Authorization')).toBeUndefined() + expect(normalizedHeaders(mock.history.get[0]!.headers).get('Authorization')).toBeUndefined() expect(tokenStorage.getToken()).toBeNull() }, ) From 4f38b1286ac2a6afe2e89442cafc0ae726b7a910 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:09:59 +0100 Subject: [PATCH 3/3] fix(http): bind expiry and retries to observed credential generations --- frontend/taskdeck-web/src/api/http.ts | 46 ++++++++++--- .../tests/utils/credentialGeneration.spec.ts | 65 +++++++++++++++++++ .../taskdeck-web/src/utils/tokenStorage.ts | 24 +++++++ 3 files changed, 126 insertions(+), 9 deletions(-) create mode 100644 frontend/taskdeck-web/src/tests/utils/credentialGeneration.spec.ts diff --git a/frontend/taskdeck-web/src/api/http.ts b/frontend/taskdeck-web/src/api/http.ts index 6a0200301..3a130cdd6 100644 --- a/frontend/taskdeck-web/src/api/http.ts +++ b/frontend/taskdeck-web/src/api/http.ts @@ -22,6 +22,8 @@ declare module 'axios' { interface AxiosRequestConfig { /** Opt out of the shared retry interceptor for bounded read operations. */ skipRetry?: boolean + /** Internal request owner; retained across automatic retries, never a credential. */ + __taskdeckCredentialGeneration?: number /** * Error statuses that are an expected part of this endpoint's contract * (e.g. a 404 from the optional card-provenance lookup for manual cards). @@ -56,6 +58,12 @@ function ensureRequestIdHeader(config: InternalAxiosRequestConfig): void { config.headers = headers } +function ownsCurrentSession(config: InternalAxiosRequestConfig | undefined): boolean { + // Observe cross-tab/storage changes before comparing the in-memory owner. + tokenStorage.getToken() + return config?.__taskdeckCredentialGeneration === tokenStorage.getObservedCredentialGeneration() +} + const http = axios.create({ // Empty in demo/static mode so a missed mock cannot fall through to loopback. baseURL: resolveApiBaseUrl(), @@ -70,14 +78,24 @@ http.interceptors.request.use( (config) => { ensureRequestIdHeader(config) - const token = tokenStorage.getToken() - if (token) { - if (isTokenExpired(token)) { - tokenStorage.clearAll() - } else { - config.headers.Authorization = `Bearer ${token}` - } + let token = tokenStorage.getToken() + if (token && isTokenExpired(token)) { + tokenStorage.clearAll() + token = null } + const generation = tokenStorage.getObservedCredentialGeneration() + if ( + config.__taskdeckCredentialGeneration !== undefined && + config.__taskdeckCredentialGeneration !== generation + ) { + throw new axios.CanceledError('Request session changed before dispatch') + } + config.__taskdeckCredentialGeneration = generation + + // Retried/reused configs can carry a bearer from a previous dispatch. + // The storage snapshot above is the only authority for this dispatch. + config.headers.delete('Authorization') + if (token) config.headers.set('Authorization', `Bearer ${token}`) return config }, (error) => Promise.reject(error) @@ -109,11 +127,15 @@ http.interceptors.response.use( logError('API Error:', safeDetails) } - // Handle 401 - clear session and redirect to login (skip in demo mode). + // Only the initiating session owns expiry side effects. A stale 401 must + // still reject to its caller, but cannot erase a replacement login. // Callers can set `skipAuth401` on the request config to suppress this // behaviour (e.g. token refresh attempts that want to handle 401 locally). const skipAuth401 = (error.config as Record | undefined)?.skipAuth401 === true - if (error.response.status === 401 && !isDemoMode && !skipAuth401) { + if ( + error.response.status === 401 && !isDemoMode && !skipAuth401 && + ownsCurrentSession(error.config) + ) { tokenStorage.clearAll() // Deliberately not awaited. Credential removal above is synchronous, and // every path that establishes a new session awaits this same deduplicated @@ -167,6 +189,9 @@ http.interceptors.response.use( if (config.skipRetry) return Promise.reject(error) if (!isRetryableError(error)) return Promise.reject(error) + if (!ownsCurrentSession(config)) { + throw new axios.CanceledError('Request session changed before retry') + } const attempt = (config.__retryCount ?? 0) + 1 if (attempt > MAX_RETRIES) return Promise.reject(error) @@ -209,6 +234,9 @@ http.interceptors.response.use( if (signal?.aborted) { return Promise.reject(new axios.CanceledError('Request aborted while waiting to retry')) } + if (!ownsCurrentSession(config)) { + throw new axios.CanceledError('Request session changed while waiting to retry') + } return http.request(config) }, ) diff --git a/frontend/taskdeck-web/src/tests/utils/credentialGeneration.spec.ts b/frontend/taskdeck-web/src/tests/utils/credentialGeneration.spec.ts new file mode 100644 index 000000000..531e184aa --- /dev/null +++ b/frontend/taskdeck-web/src/tests/utils/credentialGeneration.spec.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import * as storage from '../../utils/tokenStorage' + +const first = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJmaXJzdCJ9.synthetic' +const second = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzZWNvbmQifQ.synthetic' + +function observedGeneration(): number { + storage.getToken() + return storage.getObservedCredentialGeneration() +} + +describe('credential generation', () => { + beforeEach(() => storage.clearAll()) + + it('keeps repeated reads in the same generation', () => { + storage.setToken(first) + const before = observedGeneration() + expect(observedGeneration()).toBe(before) + }) + + it('invalidates owners when the same token is explicitly reinstalled', () => { + storage.setToken(first) + const before = observedGeneration() + storage.setToken(first) + expect(observedGeneration()).toBeGreaterThan(before) + }) + + it('distinguishes logout and same-token re-login', () => { + storage.setToken(first) + const before = observedGeneration() + storage.clearAll() + const loggedOut = observedGeneration() + storage.setToken(first) + expect(loggedOut).toBeGreaterThan(before) + expect(observedGeneration()).toBeGreaterThan(loggedOut) + }) + + it('does not invalidate on rejected token writes or session metadata updates', () => { + storage.setToken(first) + const before = observedGeneration() + expect(storage.setToken('invalid')).toBe(false) + storage.setSession({ userId: 'first', username: 'renamed', email: 'first@example.test' }) + expect(observedGeneration()).toBe(before) + }) + + it('observes external replacement and removal on the next token read', () => { + storage.setToken(first) + const before = observedGeneration() + localStorage.setItem('taskdeck_token', second) + expect(storage.getToken()).toBe(second) + const replaced = storage.getObservedCredentialGeneration() + expect(replaced).toBeGreaterThan(before) + localStorage.removeItem('taskdeck_token') + expect(observedGeneration()).toBeGreaterThan(replaced) + }) + + it('invalidates the observed credential when malformed storage is removed', () => { + storage.setToken(first) + const before = observedGeneration() + localStorage.setItem('taskdeck_token', 'invalid') + expect(storage.getToken()).toBeNull() + expect(storage.getObservedCredentialGeneration()).toBeGreaterThan(before) + expect(localStorage.getItem('taskdeck_token')).toBeNull() + }) +}) diff --git a/frontend/taskdeck-web/src/utils/tokenStorage.ts b/frontend/taskdeck-web/src/utils/tokenStorage.ts index 80eec7eeb..538bfd2a5 100644 --- a/frontend/taskdeck-web/src/utils/tokenStorage.ts +++ b/frontend/taskdeck-web/src/utils/tokenStorage.ts @@ -11,6 +11,26 @@ import { parseJwtPayload } from './jwt' const TOKEN_KEY = 'taskdeck_token' const SESSION_KEY = 'taskdeck_session' +// In-memory ownership only: never persisted or sent to the server. Explicit +// token writes/removals advance even when the token string is unchanged, so +// logout followed by same-token login cannot revive an old request owner. +let credentialGeneration = 0 +let observedToken: string | null = null + +function advanceCredentialGeneration(token: string | null): void { + observedToken = token + credentialGeneration++ +} + +/** + * Generation of the last observed credential. Read getToken() immediately + * before taking/checking a request snapshot; that observes external storage + * changes too. No additional copy of the token belongs on request metadata. + */ +export function getObservedCredentialGeneration(): number { + return credentialGeneration +} + /** Maximum allowed length for a stored token string. */ const MAX_TOKEN_LENGTH = 4096 @@ -83,8 +103,10 @@ export function getToken(): string | null { if (token && !isValidJwtStructure(token)) { // Corrupted or malicious value — remove it localStorage.removeItem(TOKEN_KEY) + if (observedToken !== null) advanceCredentialGeneration(null) return null } + if (token !== observedToken) advanceCredentialGeneration(token) return token } @@ -93,11 +115,13 @@ export function setToken(token: string): boolean { return false } localStorage.setItem(TOKEN_KEY, token) + advanceCredentialGeneration(token) return true } export function removeToken(): void { localStorage.removeItem(TOKEN_KEY) + advanceCredentialGeneration(null) } // --- Session metadata operations ---