import React, { useState, useEffect, useRef, useContext, useCallback } from 'react' import { tokenStore } from './auth' import { generatePKCEPair, generateState } from './auth' import { readConfig } from './config' // ── Types ───────────────────────────────────────────────────────────────────── export interface AuthState { token: string | null user: { name?: string } | null status: 'unauthenticated' | 'authenticated' | 'auth_error' error?: string /** True once a token has been set at least once this page-load (supports IV7). */ hasBeenAuthenticated: boolean } export interface AuthContextValue { state: AuthState signIn(returnTo?: string): void signOut(): void reportAuthError(error: string): void } // ── PKCE pending state shape stored in localStorage ────────────────────────── interface PendingAuth { verifier: string state: string returnTo: string expiresAt: number // ms epoch } const PENDING_KEY = 'lethe_auth_pending' // ── ID token parsing (cosmetic name claim only) ─────────────────────────────── function parseNameFromToken(token: string): string | undefined { try { const parts = token.split('.') if (parts.length < 2) return undefined // base64url → base64 → decode const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/') const json = atob(payload) const claims = JSON.parse(json) as Record if (typeof claims['name'] === 'string') { return claims['name'] } return undefined } catch { // Malformed token — ignore; still treat as authenticated (token is what // middleware checks; name is purely cosmetic). return undefined } } // ── Context ─────────────────────────────────────────────────────────────────── export const AuthContext = React.createContext(null) /** * useAuth — typed accessor for AuthContextValue. * Throws if used outside AuthProvider. */ export function useAuth(): AuthContextValue { const ctx = useContext(AuthContext) if (ctx === null) { throw new Error('useAuth must be used within AuthProvider') } return ctx } // ── AuthProvider ────────────────────────────────────────────────────────────── export function AuthProvider({ children }: { children: React.ReactNode }): React.JSX.Element { // Try reading config once at provider mount; if absent, surface a config-error card. const [configError, setConfigError] = React.useState(null) const configRef = useRef | null>(null) if (configRef.current === null && configError === null) { try { configRef.current = readConfig() } catch (e) { const msg = e instanceof Error ? e.message : String(e) // We can't call setState during render directly, so we schedule it. // But for the initial render we use a lazy-init approach: // configRef stays null and configError is set below. // Since this runs synchronously before first paint, use a flag instead. // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(configRef as any)._error = msg } } // Initialise from tokenStore current value so SSR/hydration is consistent. const initialToken = tokenStore.get() const [state, setState] = useState(() => { if (initialToken != null) { const name = parseNameFromToken(initialToken) return { token: initialToken, user: { name }, status: 'authenticated', hasBeenAuthenticated: true, } } return { token: null, user: null, status: 'unauthenticated', hasBeenAuthenticated: false, } }) // hasBeenAuthenticated ref: stays true once first token received (IV7). const hasBeenAuthenticatedRef = useRef(state.hasBeenAuthenticated) useEffect(() => { // Propagate config error detected during render into React state. // eslint-disable-next-line @typescript-eslint/no-explicit-any const errFromRender = (configRef as any)._error as string | undefined if (errFromRender != null) { setConfigError(errFromRender) return } const unsubscribe = tokenStore.subscribe((token) => { if (token != null) { hasBeenAuthenticatedRef.current = true const name = parseNameFromToken(token) setState({ token, user: { name }, status: 'authenticated', hasBeenAuthenticated: true, }) } else { setState({ token: null, user: null, status: 'unauthenticated', hasBeenAuthenticated: hasBeenAuthenticatedRef.current, }) } }) return unsubscribe // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // mount-only const signIn = useCallback((returnTo?: string): void => { const cfg = configRef.current if (cfg === null) { // Config missing — cannot sign in; surface error. setState((prev) => ({ ...prev, status: 'auth_error', error: 'lethe config missing', })) return } void (async () => { const { verifier, challenge } = await generatePKCEPair() const stateParam = generateState() const rt = returnTo ?? '/' const pending: PendingAuth = { verifier, state: stateParam, returnTo: rt, expiresAt: Date.now() + 5 * 60 * 1000, // 5 min TTL } localStorage.setItem(PENDING_KEY, JSON.stringify(pending)) const origin = window.location.origin const params = new URLSearchParams({ response_type: 'code', client_id: cfg.clientId, redirect_uri: `${origin}/auth/callback`, state: stateParam, code_challenge: challenge, code_challenge_method: 'S256', }) window.location.assign(`${cfg.issuer}/authorize?${params.toString()}`) })() }, []) const signOut = useCallback((): void => { tokenStore.set(null) // localStorage PKCE pending is left alone (harmless; will expire naturally). // IV4: no tokens in localStorage — nothing to clear there. }, []) const reportAuthError = useCallback((error: string): void => { setState((prev) => ({ ...prev, status: 'auth_error', error, hasBeenAuthenticated: hasBeenAuthenticatedRef.current, })) }, []) if (configError != null) { return (
auth-config missing
window.__LETHE_CONFIG__ was not injected. Reload or contact your administrator.
) } const value: AuthContextValue = { state, signIn, signOut, reportAuthError } return ( {children} ) }