import { createContext, useCallback, useContext } from "react" import { decodeJwt, isExpired } from "@/auth/jwt" /** * Light-auth context + hooks (Phase 8), kept separate from the provider component * so the provider file can export only a component (fast-refresh friendly). * * The JWT issued by the broker (auth.sara.ai) lives in localStorage and is handed * to Convex via `useConvexLightAuth` (the `ConvexProviderWithAuth` adapter). * Convex validates it on every request; decoded claims here only drive the UI. * Two login lanes — the public reader lane (Google) and the admin lane (WorkOS, * reached from `/admin`) — both land on `/auth/callback?code=…`. */ export const AUTH_BASE_URL = "https://auth.sara.ai" export const STORAGE_KEY = "lekterion.authToken" /** Where to send the reader after login completes (set just before redirect). */ export const POST_LOGIN_KEY = "lekterion.postLogin" /** Per-attempt CSRF nonce: stashed at startLogin, re-checked at the callback. */ export const LOGIN_STATE_KEY = "lekterion.loginState" export interface LightAuthValue { token: string | null isAuthenticated: boolean isAdmin: boolean nickname: string | null /** Start the public reader login (Google lane). */ login: () => void /** Start the admin login (WorkOS lane). */ adminLogin: () => void logout: () => void /** * Drop a token the server rejected as invalid (see `isUnauthenticatedError`). * Unlike `logout` this is a self-heal for a stale-but-unexpired token: clearing * it flips the UI to logged-out so the reader re-logs in, instead of leaving a * dead session that keeps failing every gated call. */ clearStaleSession: () => void /** Store (or clear) the session JWT — used by the auth callback. */ setToken: (token: string | null) => void } export const AuthContext = createContext(null) /** Read the stored token, discarding it if absent/expired. */ export function loadToken(): string | null { const token = localStorage.getItem(STORAGE_KEY) if (!token) return null if (isExpired(decodeJwt(token))) { localStorage.removeItem(STORAGE_KEY) return null } return token } /** Redirect to the broker to begin a login (reader = Google, admin = WorkOS). */ export function startLogin(kind: "reader" | "admin"): void { // Bind this login to this browser: a random nonce rides in returnTo (the // broker preserves returnTo's query params and only appends ?code=), and the // callback rejects any code whose returned nonce doesn't match what we stored. // Without it, an attacker's own valid code delivered to a victim would log the // victim into the attacker's account (login CSRF / session fixation). const state = crypto.randomUUID() sessionStorage.setItem(LOGIN_STATE_KEY, state) const returnUrl = new URL(`${window.location.origin}/auth/callback`) returnUrl.searchParams.set("s", state) const returnTo = returnUrl.toString() // Return the reader to wherever they were after the round-trip. sessionStorage.setItem( POST_LOGIN_KEY, window.location.pathname + window.location.search ) const url = kind === "admin" ? `${AUTH_BASE_URL}/redirect?autoSignIn=true&returnTo=${encodeURIComponent(returnTo)}` : `${AUTH_BASE_URL}/light/google/start?returnTo=${encodeURIComponent(returnTo)}` window.location.assign(url) } export const useLightAuth = (): LightAuthValue => { const value = useContext(AuthContext) if (!value) throw new Error("useLightAuth must be used within ") return value } /** * Adapter for `ConvexProviderWithAuth`. Returns the stored JWT (or null when * absent/expired) — there is no refresh endpoint, so an expired token simply * reads as logged-out and the reader re-logs in. Must run inside ``. */ export const useConvexLightAuth = (): { isLoading: boolean isAuthenticated: boolean fetchAccessToken: (args: { forceRefreshToken: boolean }) => Promise } => { const { token } = useLightAuth() const fetchAccessToken = useCallback((): Promise => { if (!token) return Promise.resolve(null) return Promise.resolve(isExpired(decodeJwt(token)) ? null : token) }, [token]) // Expiry-aware so it never disagrees with `fetchAccessToken`: an expired token // reads as logged-out here just as it returns null there. const isAuthenticated = token !== null && !isExpired(decodeJwt(token)) return { isLoading: false, isAuthenticated, fetchAccessToken } }