fix(auth): reject expired JWT on session read (silent-401 shell)
readFromStorage validated token shape but never checked exp, so an expired token mounted the full authed shell and every API call 401d silently. Decode the JWT and treat an expired token as no session. Pattern backported from skyai-finance. Frontend audit 2026-06-20, rank 1. Also clears the localStorage Session in onUnauthorized (root.tsx) so a 401 fully logs out instead of leaving a dead session behind getToken. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,10 @@ function readFromStorage(): Session | null {
|
|||||||
typeof parsed.token !== "string"
|
typeof parsed.token !== "string"
|
||||||
)
|
)
|
||||||
return null
|
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 {
|
return {
|
||||||
userId: parsed.userId,
|
userId: parsed.userId,
|
||||||
name:
|
name:
|
||||||
@@ -76,6 +80,35 @@ export function loadSession(): Session | null {
|
|||||||
return readFromStorage()
|
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() {
|
export function signOut() {
|
||||||
if (typeof window === "undefined") return
|
if (typeof window === "undefined") return
|
||||||
localStorage.removeItem(STORAGE_KEY)
|
localStorage.removeItem(STORAGE_KEY)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { CommandBusProvider } from "@crema/action-bus"
|
|||||||
import { ArcadiaProvider } from "@crema/arcadia-client"
|
import { ArcadiaProvider } from "@crema/arcadia-client"
|
||||||
import { LlmConfigBootstrap } from "~/lib/llm-config-bootstrap"
|
import { LlmConfigBootstrap } from "~/lib/llm-config-bootstrap"
|
||||||
import { ProfileBootstrap } from "~/lib/profile-bootstrap"
|
import { ProfileBootstrap } from "~/lib/profile-bootstrap"
|
||||||
|
import { signOut } from "~/lib/session"
|
||||||
// CREMA:PROVIDERS-IMPORTS
|
// CREMA:PROVIDERS-IMPORTS
|
||||||
|
|
||||||
const ARCADIA_URL = import.meta.env.VITE_ARCADIA_URL ?? "http://localhost:4000"
|
const ARCADIA_URL = import.meta.env.VITE_ARCADIA_URL ?? "http://localhost:4000"
|
||||||
@@ -59,6 +60,10 @@ export default function App() {
|
|||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
sessionStorage.removeItem("arcadia_access_token")
|
sessionStorage.removeItem("arcadia_access_token")
|
||||||
sessionStorage.removeItem("arcadia_refresh_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()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user