/** * JWT helpers shared by the edge middleware and server components. * * Pure functions with no `next/headers` dependency, so they are safe to import * from `middleware.ts` (edge runtime) and from RSCs alike. They only inspect the * token's `exp` claim — a cheap liveness check for routing/UX, NOT a security * boundary. The signature is never verified here; the API server is the only * authority that actually trusts the token. */ export interface JwtPayload { exp?: number; sub?: string; [claim: string]: unknown; } // JWTs use base64url (no padding, `-`/`_` instead of `+`/`/`); atob expects // standard base64, so normalise before decoding. function base64UrlDecode(segment: string): string { const base64 = segment.replace(/-/g, '+').replace(/_/g, '/'); const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '='); return atob(padded); } export function decodeJwtPayload(token: string | undefined): JwtPayload | null { if (!token) return null; const segment = token.split('.')[1]; if (!segment) return null; try { return JSON.parse(base64UrlDecode(segment)) as JwtPayload; } catch { return null; } } /** Compact serialization part counts: a JWS is 3, a JWE is 5 (header.key.iv.ciphertext.tag). */ const JWE_PART_COUNT = 5; export function isTokenAlive(token: string | undefined): boolean { if (!token) return false; const payload = decodeJwtPayload(token); if (payload) return typeof payload.exp === 'number' && payload.exp * 1000 > Date.now(); /* * The server issues an **encrypted** access token (JWE — `JwtService._createSecurityTokenAsync` * pairs SigningCredentials with EncryptingCredentials), so its claims, `exp` included, are * ciphertext that no client-side decode will ever read. Treating that as "not alive" is what * made every real session look logged-out to this gate: the middleware bounced private routes * to /login, and an authenticated hit on '/' fell through the guest-front-door branch and got * rewritten to the marketing page — the "app just shows a loading skeleton at /fa" symptom. * * A well-formed but unreadable token is therefore treated as alive. That is sound here and only * here: this is a routing/UX hint, never a security boundary (the API is the sole authority on * the token), and the access cookie's own 15-minute `maxAge` bounds how long the optimism can * last — an expired cookie is simply absent, which is still a clean `false`. A stale token that * slips through gets a 401 on the first call, which the fetch layer's silent refresh handles. */ return token.split('.').length === JWE_PART_COUNT; }