Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 37 additions & 9 deletions frontend/taskdeck-web/src/api/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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(),
Expand All @@ -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)
Expand Down Expand Up @@ -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<string, unknown> | 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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
},
)
Expand Down
180 changes: 180 additions & 0 deletions frontend/taskdeck-web/src/tests/api/httpSessionOwnership.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/** 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 normalizedHeaders(value: unknown): AxiosHeaders {
if (!(value instanceof AxiosHeaders)) throw new Error('Expected normalized Axios request headers')
return value
}

function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((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<void>()
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 = 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()
})

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(normalizedHeaders(mock.history.get[0]!.headers).get('Authorization')).toBeUndefined()
expect(tokenStorage.getToken()).toBeNull()
},
)
})
65 changes: 65 additions & 0 deletions frontend/taskdeck-web/src/tests/utils/credentialGeneration.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
24 changes: 24 additions & 0 deletions frontend/taskdeck-web/src/utils/tokenStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
}

Expand All @@ -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 ---
Expand Down
Loading