diff --git a/app/lib/session.ts b/app/lib/session.ts index 9492fa4..3f3ca4d 100644 --- a/app/lib/session.ts +++ b/app/lib/session.ts @@ -44,6 +44,10 @@ function readFromStorage(): Session | null { typeof parsed.token !== "string" ) return null + // An expired JWT is not a session: without this the shell renders as + // "logged in" and every API call 401s silently. Treat it as null so the + // app bounces to /login. (Frontend audit 2026-06-20.) + if (isTokenExpired(parsed.token)) return null return { userId: parsed.userId, name: @@ -76,6 +80,35 @@ export function loadSession(): Session | null { return readFromStorage() } +// A token counts as expired only if it's a JWT carrying an `exp` in the past +// (minus a small clock-skew grace). Non-JWT dev/mock tokens (no decodable +// `exp`) are treated as non-expiring so offline/test flows keep working. +const TOKEN_EXPIRY_SKEW_S = 30 +export function isTokenExpired(token: string | undefined | null): boolean { + if (!token) return true + const claims = decodeJwtPayload(token) + const exp = + claims && typeof claims.exp === "number" ? (claims.exp as number) : null + if (exp === null) return false + return Date.now() / 1000 >= exp - TOKEN_EXPIRY_SKEW_S +} + +function decodeJwtPayload(token: string): Record | null { + const parts = token.split(".") + if (parts.length !== 3) return null + try { + const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/") + const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4)) + const json = + typeof atob === "function" + ? atob(b64 + pad) + : Buffer.from(b64 + pad, "base64").toString("utf-8") + return JSON.parse(json) as Record + } catch { + return null + } +} + export function signOut() { if (typeof window === "undefined") return localStorage.removeItem(STORAGE_KEY) diff --git a/app/root.tsx b/app/root.tsx index 33eff63..8abc983 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -15,6 +15,7 @@ import { CommandBusProvider } from "@crema/action-bus" import { ArcadiaProvider } from "@crema/arcadia-client" import { LlmConfigBootstrap } from "~/lib/llm-config-bootstrap" import { ProfileBootstrap } from "~/lib/profile-bootstrap" +import { signOut } from "~/lib/session" // CREMA:PROVIDERS-IMPORTS const ARCADIA_URL = import.meta.env.VITE_ARCADIA_URL ?? "http://localhost:4000" @@ -59,6 +60,10 @@ export default function App() { if (typeof window !== "undefined") { sessionStorage.removeItem("arcadia_access_token") sessionStorage.removeItem("arcadia_refresh_token") + // Also clear the localStorage Session (crema.session); otherwise + // useSession() still reports "logged in" after a 401 and the shell + // keeps mounting with a dead token. (Frontend audit 2026-06-20.) + signOut() } }} >