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
43 changes: 43 additions & 0 deletions docs/analysis/2026-09-21-jwt-payload-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# JWT payload admission boundary

Status: draft PR #3325, 2026-09-21. Base: `307c3b8b50bec1cb0bfaea3e570a942bcb1d4451`.

## Reproduced defect

`jwt.ts` cast arbitrary JSON to `JwtPayload`. Wrong-type or out-of-range `exp`
claims could reach `Date.toISOString()` and throw. `sessionStore.setSession`
sets reactive identity fields before formatting the expiry, so admission must
reject these payloads before that point. `exp: 0` was also mistaken for an absent
claim, and binary strings returned by `atob` corrupted UTF-8 claims.

## Contract

- Decode UTF-8 strictly and accept only a non-null, non-array JSON object.
- An optional expiry must be a finite number representable by JavaScript Date.
- Preserve valid fractional, negative and boundary NumericDates.
- Epoch zero is present and expired. An absent expiry retains the existing policy.
- Invalid payloads are unusable, not implicitly non-expiring.
- `tokenStorage` applies this validation before persistence and during restoration.
- This is client-side payload admission, not cryptographic verification or a
replacement for server-side authentication/authorization.

No route, DTO, schema, dependency, migration or server authorization changes.
Public function signatures are unchanged. Reverting the patch needs no data migration.

## Verification and remaining gates

A supplemental Node 22 runner imported the actual production TypeScript modules:
29 cases passed; 24 of those cases failed against the original implementation.
It also exercised the real token-storage functions using an in-memory storage
fixture. A standalone TypeScript 5.8.3 check of the production modules passed.

Canonical regressions were added to `jwt.spec.ts` and `tokenStorage.spec.ts`.
They have NOT been executed in Vitest in this environment. The required Node 24
runtime/dependencies were unavailable; npm registry DNS failed. The supplemental
runner and older standalone compiler do not replace project qualification.

Before ready-for-review: run frontend lint, `npm run typecheck`, `npm run build`,
the full Vitest suite and exact-head hosted CI on the pinned Node 24 toolchain.
Obtain independent review, including the compatibility of the unchanged
missing-expiry policy and invalid-token failure behavior. No release readiness,
secrets-scan result, approval or deployment is claimed by this note.
58 changes: 57 additions & 1 deletion frontend/taskdeck-web/src/tests/utils/jwt.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'
import { getTokenExpiryIso, isTokenExpired, parseJwtPayload } from '../../utils/jwt'

function toBase64Url(value: string): string {
return btoa(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
const bytes = new TextEncoder().encode(value)
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
}

function createToken(payload: Record<string, unknown>): string {
Expand Down Expand Up @@ -48,3 +49,58 @@ describe('jwt utils', () => {
expect(parseJwtPayload('header.!!!invalid!!!.sig')).toBeNull()
})
})

describe('JWT payload admission', () => {
function tokenFromJson(json: string): string {
return `header.${toBase64Url(json)}.sig`
}

it.each(['null', 'true', '42', '"text"', '[]', '[{"exp":1}]'])(
'rejects non-object payload %s', (json) => {
expect(parseJwtPayload(tokenFromJson(json))).toBeNull()
expect(isTokenExpired(tokenFromJson(json))).toBe(true)
},
)

it.each([
'null', 'false', '"1893456000"', '"bad"', '[]', '[1893456000]', '{}',
'1e400', '-1e400', '1e100', '-1e100', '8640000000001', '-8640000000001',
])('rejects unusable exp %s without throwing during session setup', (exp) => {
const token = tokenFromJson(`{"exp":${exp}}`)
expect(getTokenExpiryIso(token)).toBeNull()
expect(parseJwtPayload(token)).toBeNull()
expect(isTokenExpired(token)).toBe(true)
})

it('treats epoch zero as expired rather than absent', () => {
const token = createToken({ exp: 0 })
expect(getTokenExpiryIso(token)).toBe('1970-01-01T00:00:00.000Z')
expect(isTokenExpired(token)).toBe(true)
})

it.each([-8640000000000, -1.25, 1893456000.125, 8640000000000])(
'preserves a representable numeric exp %s', (exp) => {
const token = createToken({ exp })
expect(parseJwtPayload(token)?.exp).toBe(exp)
expect(getTokenExpiryIso(token)).toBe(new Date(exp * 1000).toISOString())
},
)

it('decodes UTF-8 claims without corrupting non-ASCII text', () => {
const payload = { sub: 'Ștefan 日本語 🌌', exp: 1893456000 }
expect(parseJwtPayload(createToken(payload))).toEqual(payload)
})

it('rejects invalid UTF-8 rather than accepting Latin-1 as JSON', () => {
const binary = '{"sub":"' + String.fromCharCode(0xc3, 0x28) + '"}'
const payload = btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
expect(parseJwtPayload(`header.${payload}.sig`)).toBeNull()
})

it.each(['header-only', 'header.!!!invalid!!!.sig', tokenFromJson('{bad')])(
'treats malformed input %s as unusable', (token) => {
expect(getTokenExpiryIso(token)).toBeNull()
expect(isTokenExpired(token)).toBe(true)
},
)
})
17 changes: 17 additions & 0 deletions frontend/taskdeck-web/src/tests/utils/tokenStorage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ function createFakeJwt(payload: Record<string, unknown> = { sub: 'user-1' }): st
}

describe('isValidJwtStructure', () => {
it.each(['null', 'true', '42', '[]', '{"exp":"1893456000"}', '{"exp":1e100}'])(
'rejects unusable payload %s at the session admission boundary', (json) => {
expect(isValidJwtStructure(`header.${toBase64Url(json)}.signature`)).toBe(false)
},
)

it('accepts a well-formed three-part JWT', () => {
expect(isValidJwtStructure(createFakeJwt())).toBe(true)
})
Expand Down Expand Up @@ -156,6 +162,17 @@ describe('token storage operations', () => {
expect(localStorage.getItem('taskdeck_token')).toBeNull()
})

it('refuses to persist an expiry that cannot be formatted', () => {
expect(setToken(createFakeJwt({ exp: 1e100 }))).toBe(false)
expect(localStorage.getItem('taskdeck_token')).toBeNull()
})

it('removes a persisted token with an unusable expiry during restoration', () => {
localStorage.setItem('taskdeck_token', createFakeJwt({ exp: 1e100 }))
expect(getToken()).toBeNull()
expect(localStorage.getItem('taskdeck_token')).toBeNull()
})

it('removeToken clears the stored token', () => {
setToken(createFakeJwt())
removeToken()
Expand Down
22 changes: 17 additions & 5 deletions frontend/taskdeck-web/src/utils/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ function decodeBase64Url(value: string): string | null {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
const paddingLength = (4 - (normalized.length % 4)) % 4
const padded = normalized + '='.repeat(paddingLength)
return atob(padded)
const bytes = Uint8Array.from(atob(padded), char => char.charCodeAt(0))
return new TextDecoder('utf-8', { fatal: true }).decode(bytes)
} catch {
return null
}
Expand All @@ -22,21 +23,32 @@ export function parseJwtPayload(token: string): JwtPayload | null {
if (!decoded) return null

try {
const payload = JSON.parse(decoded) as JwtPayload
return payload
const payload: unknown = JSON.parse(decoded)
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null

// An optional NumericDate must be usable by every session consumer, including
// ISO formatting. Invalid claims must not turn into a non-expiring session.
if ('exp' in payload && (
typeof payload.exp !== 'number'
|| !Number.isFinite(payload.exp)
|| !Number.isFinite(new Date(payload.exp * 1000).getTime())
)) return null

return payload as JwtPayload
} catch {
return null
}
}

export function getTokenExpiryIso(token: string): string | null {
const payload = parseJwtPayload(token)
if (!payload?.exp) return null
if (payload?.exp === undefined) return null
return new Date(payload.exp * 1000).toISOString()
}

export function isTokenExpired(token: string): boolean {
const payload = parseJwtPayload(token)
if (!payload?.exp) return false
if (!payload) return true
if (payload.exp === undefined) return false
return Date.now() >= payload.exp * 1000
}
3 changes: 2 additions & 1 deletion frontend/taskdeck-web/src/utils/tokenStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ export interface PersistedSession {

/**
* Validates that a string has the basic structure of a JWT (three base64url-encoded segments).
* This is a structural check only — it does not verify signatures or claims.
* Checks UTF-8 object payloads and the shape/range of an optional expiry too.
* Does not authenticate the token or verify its signature, issuer, or audience.
*/
export function isValidJwtStructure(token: string): boolean {
if (!token || typeof token !== 'string') return false
Expand Down
Loading