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<string, unknown>
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<AuthContextValue | null>(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<string | null>(null)
const configRef = useRef<ReturnType<typeof readConfig> | 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<AuthState>(() => {
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 (
<div
className="body body-pad"
style={{ display: 'flex', justifyContent: 'center', paddingTop: 60 }}
>
<div className="card" style={{ padding: '24px 32px', textAlign: 'center' }}>
<div className="uppercase-mono" style={{ marginBottom: 8 }}>auth-config missing</div>
<div className="muted">
window.__LETHE_CONFIG__ was not injected. Reload or contact your administrator.
</div>
</div>
</div>
)
}
const value: AuthContextValue = { state, signIn, signOut, reportAuthError }
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
)
}