Something went wrong. Try again.
Original-language reader for Kant, Aristotle & more: select any passage to gloss, translate, or hear it read aloud, with exact citations.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111import { 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<LightAuthValue | null>(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 <AuthProvider>") 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 `<AuthProvider>`. */export const useConvexLightAuth = (): { isLoading: boolean isAuthenticated: boolean fetchAccessToken: (args: { forceRefreshToken: boolean }) => Promise<string | null>} => { const { token } = useLightAuth()
const fetchAccessToken = useCallback((): Promise<string | null> => { 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 }}