fix(auth): reject expired JWT on session read #2

Open
jules wants to merge 1 commits from fix/audit-jwt-expiry-main into main
2 changed files with 38 additions and 0 deletions
Showing only changes of commit ea34bcd886 - Show all commits

View File

@@ -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<string, unknown> | 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<string, unknown>
} catch {
return null
}
}
export function signOut() {
if (typeof window === "undefined") return
localStorage.removeItem(STORAGE_KEY)

View File

@@ -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()
}
}}
>