From 8725acb3e78dec8167210a69b6fcd7e23a167745 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:44:57 +0100 Subject: [PATCH 1/2] fix(auth): reject unusable JWT payloads before session admission Validate object-shaped UTF-8 payloads and representable NumericDates before storage or session use. Preserve the existing missing-expiry policy, handle epoch zero explicitly, and add focused regression coverage and an evidence note. --- .../2026-09-21-jwt-payload-boundary.md | 43 ++++++++++++++ .../taskdeck-web/src/tests/utils/jwt.spec.ts | 58 ++++++++++++++++++- .../src/tests/utils/tokenStorage.spec.ts | 17 ++++++ frontend/taskdeck-web/src/utils/jwt.ts | 22 +++++-- .../taskdeck-web/src/utils/tokenStorage.ts | 3 +- 5 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 docs/analysis/2026-09-21-jwt-payload-boundary.md diff --git a/docs/analysis/2026-09-21-jwt-payload-boundary.md b/docs/analysis/2026-09-21-jwt-payload-boundary.md new file mode 100644 index 0000000000..4722f7e678 --- /dev/null +++ b/docs/analysis/2026-09-21-jwt-payload-boundary.md @@ -0,0 +1,43 @@ +# JWT payload admission boundary + +Status: unpublished draft candidate, 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. diff --git a/frontend/taskdeck-web/src/tests/utils/jwt.spec.ts b/frontend/taskdeck-web/src/tests/utils/jwt.spec.ts index a7c8035df1..add7ec3f14 100644 --- a/frontend/taskdeck-web/src/tests/utils/jwt.spec.ts +++ b/frontend/taskdeck-web/src/tests/utils/jwt.spec.ts @@ -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 { @@ -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) + }, + ) +}) diff --git a/frontend/taskdeck-web/src/tests/utils/tokenStorage.spec.ts b/frontend/taskdeck-web/src/tests/utils/tokenStorage.spec.ts index 1a554396a4..40c9026be3 100644 --- a/frontend/taskdeck-web/src/tests/utils/tokenStorage.spec.ts +++ b/frontend/taskdeck-web/src/tests/utils/tokenStorage.spec.ts @@ -22,6 +22,12 @@ function createFakeJwt(payload: Record = { 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) }) @@ -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() diff --git a/frontend/taskdeck-web/src/utils/jwt.ts b/frontend/taskdeck-web/src/utils/jwt.ts index 51f13edcf2..d3418ae290 100644 --- a/frontend/taskdeck-web/src/utils/jwt.ts +++ b/frontend/taskdeck-web/src/utils/jwt.ts @@ -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 } @@ -22,8 +23,18 @@ 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 } @@ -31,12 +42,13 @@ export function parseJwtPayload(token: string): JwtPayload | 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 } diff --git a/frontend/taskdeck-web/src/utils/tokenStorage.ts b/frontend/taskdeck-web/src/utils/tokenStorage.ts index 80eec7eeb2..8529d36c35 100644 --- a/frontend/taskdeck-web/src/utils/tokenStorage.ts +++ b/frontend/taskdeck-web/src/utils/tokenStorage.ts @@ -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 From a37539a42427e1b1fa8d0e63205b00a23fc6434b Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:45:46 +0100 Subject: [PATCH 2/2] docs(auth): link JWT evidence to draft PR --- docs/analysis/2026-09-21-jwt-payload-boundary.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/analysis/2026-09-21-jwt-payload-boundary.md b/docs/analysis/2026-09-21-jwt-payload-boundary.md index 4722f7e678..07dc149b70 100644 --- a/docs/analysis/2026-09-21-jwt-payload-boundary.md +++ b/docs/analysis/2026-09-21-jwt-payload-boundary.md @@ -1,6 +1,6 @@ # JWT payload admission boundary -Status: unpublished draft candidate, 2026-09-21. Base: `307c3b8b50bec1cb0bfaea3e570a942bcb1d4451`. +Status: draft PR #3325, 2026-09-21. Base: `307c3b8b50bec1cb0bfaea3e570a942bcb1d4451`. ## Reproduced defect