Files
arcadia-admin/app/lib/session.ts
jules ea34bcd886 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>
2026-06-20 20:24:20 +10:00

230 lines
7.4 KiB
TypeScript

// Session — minimal auth scaffold backed by localStorage.
// Sign-in is owned by `persistFromArcadiaLogin`, which is called by the auth
// routes after a successful arcadia API exchange. The shape here matches what
// AppShell + useUser expect.
import { useEffect, useSyncExternalStore } from "react"
import { profileInitials } from "~/lib/profile"
import { decodeJwt, type AvailableTenantClaim } from "~/lib/jwt"
export type AvailableTenant = {
id: string
slug?: string
name?: string
roles: string[]
}
export type Session = {
userId: string
name: string
email: string
token: string
// Issued at, ms since epoch.
issuedAt: number
// Active membership context — derived from the JWT.
tenantId?: string
tenantSlug?: string
roles: string[]
availableTenants: AvailableTenant[]
}
const STORAGE_KEY = "crema.session"
const CHANGE_EVENT = "crema:session-change"
function readFromStorage(): Session | null {
if (typeof window === "undefined") return null
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as Partial<Session>
if (
typeof parsed.userId !== "string" ||
typeof parsed.email !== "string" ||
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:
typeof parsed.name === "string" && parsed.name.trim()
? parsed.name
: parsed.email,
email: parsed.email,
token: parsed.token,
issuedAt:
typeof parsed.issuedAt === "number" ? parsed.issuedAt : Date.now(),
tenantId: typeof parsed.tenantId === "string" ? parsed.tenantId : undefined,
tenantSlug:
typeof parsed.tenantSlug === "string" ? parsed.tenantSlug : undefined,
roles: Array.isArray(parsed.roles)
? parsed.roles.filter((r): r is string => typeof r === "string")
: [],
availableTenants: Array.isArray(parsed.availableTenants)
? (parsed.availableTenants.filter(
(t): t is AvailableTenant =>
!!t && typeof (t as AvailableTenant).id === "string",
) as AvailableTenant[])
: [],
}
} catch {
return null
}
}
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)
sessionStorage.removeItem("arcadia_access_token")
sessionStorage.removeItem("arcadia_refresh_token")
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
}
/** Bridge: persist a Session record from a successful arcadia login.
* Stores the JWT in sessionStorage (where ArcadiaProvider's getToken reads
* it) and writes the user-shaped Session into localStorage so the existing
* AppShell / useUser machinery keeps working unchanged. */
export function persistFromArcadiaLogin(
tokens: { access_token: string; refresh_token?: string },
user?: { id: string; email: string; full_name?: string; first_name?: string; last_name?: string } | null,
): Session {
const name =
user?.full_name ||
[user?.first_name, user?.last_name].filter(Boolean).join(" ") ||
user?.email ||
"Signed-in user"
const claims = decodeJwt(tokens.access_token) ?? {}
const availableTenants: AvailableTenant[] = Array.isArray(
claims.available_tenants,
)
? (claims.available_tenants as AvailableTenantClaim[])
.filter((t) => t && typeof t.id === "string")
.map((t) => ({
id: t.id as string,
slug: t.slug,
name: t.name,
roles: Array.isArray(t.roles) ? t.roles : [],
}))
: []
const session: Session = {
userId: user?.id ?? `arcadia-${Date.now().toString(36)}`,
name,
email: user?.email ?? "",
token: tokens.access_token,
issuedAt: Date.now(),
tenantId:
typeof claims.tenant_id === "string" ? claims.tenant_id : undefined,
tenantSlug:
typeof claims.tenant_slug === "string" ? claims.tenant_slug : undefined,
roles: Array.isArray(claims.roles) ? claims.roles : [],
availableTenants,
}
if (typeof window !== "undefined") {
sessionStorage.setItem("arcadia_access_token", tokens.access_token)
if (tokens.refresh_token) sessionStorage.setItem("arcadia_refresh_token", tokens.refresh_token)
localStorage.setItem(STORAGE_KEY, JSON.stringify(session))
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
}
return session
}
/** Patch the stored session's identity fields without changing the token.
* Use after the operator edits their profile so the appbar avatar and
* protected-shell greeting reflect the new name/email immediately. */
export function updateSessionUser(patch: {
name?: string
email?: string
}): Session | null {
if (typeof window === "undefined") return null
const current = readFromStorage()
if (!current) return null
const next: Session = {
...current,
name: patch.name?.trim() ? patch.name : current.name,
email: patch.email?.trim() ? patch.email : current.email,
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
return next
}
/** True if a non-expired session is in storage. */
export function hasSession(): boolean {
return !!readFromStorage()
}
let cached: Session | null = null
let cacheValid = false
function subscribe(cb: () => void): () => void {
const onChange = () => {
cacheValid = false
cb()
}
window.addEventListener(CHANGE_EVENT, onChange)
window.addEventListener("storage", (e) => {
if (e.key === STORAGE_KEY) onChange()
})
return () => window.removeEventListener(CHANGE_EVENT, onChange)
}
function getSnapshot(): Session | null {
if (!cacheValid) {
cached = readFromStorage()
cacheValid = true
}
return cached
}
function getServerSnapshot(): Session | null {
return null
}
export function useSession(): Session | null {
const s = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
useEffect(() => {
cacheValid = false
}, [])
return s
}
export function sessionInitials(session: Session | null): string {
if (!session) return "?"
return profileInitials(session.name || session.email)
}