From f2376137d0f2f8cd5978839c3be663ad85527576 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 26 Apr 2026 17:57:18 +0300 Subject: [PATCH] web: /login + /auth/callback routes + auth context + config reader - web/src/lib/config.ts: readConfig() reads window.__LETHE_CONFIG__ (IF1), converts client_id -> clientId, throws on absent config (GPC6) - web/src/lib/authContext.tsx: AuthProvider subscribes to tokenStore, parses ID token name claim, exposes signIn/signOut/reportAuthError via context; hasBeenAuthenticated flag supports IV7 cold-vs-session distinction - web/src/routes/login.tsx: /login route calls signIn(return_to) on mount - web/src/routes/auth.callback.tsx: validates state+TTL, exchanges code via raw fetch to OP /token (allowed exception), stores access_token via tokenStore.set, anti-loop guard using countCallbackFailures (IV6) - web/src/routes/__root.tsx: wraps tree in AuthProvider above KeyboardCursorContext - web/src/routeTree.gen.ts: regenerated by Vite with /login and /auth/callback - internal/server/web/dist/index.html: rebuilt artifact with new asset hashes --- internal/server/web/dist/index.html | 2 +- web/src/lib/authContext.tsx | 226 +++++++++++++++++++++++++ web/src/lib/config.ts | 39 +++++ web/src/routeTree.gen.ts | 42 +++++ web/src/routes/__root.tsx | 17 +- web/src/routes/auth.callback.tsx | 246 ++++++++++++++++++++++++++++ web/src/routes/login.tsx | 32 ++++ 7 files changed, 596 insertions(+), 8 deletions(-) create mode 100644 web/src/lib/authContext.tsx create mode 100644 web/src/lib/config.ts create mode 100644 web/src/routes/auth.callback.tsx create mode 100644 web/src/routes/login.tsx diff --git a/internal/server/web/dist/index.html b/internal/server/web/dist/index.html index 30d60f4fe2fd468ba6c0a87994b536442b08a3f6..5693357865b9c5482826a8ea3df24b4ef8841d68 100644 --- a/internal/server/web/dist/index.html +++ b/internal/server/web/dist/index.html @@ -13,7 +13,7 @@ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" /> - + diff --git a/web/src/lib/authContext.tsx b/web/src/lib/authContext.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d7458c2b74f9ec7e85d1afaef119813775b59db5 --- /dev/null +++ b/web/src/lib/authContext.tsx @@ -0,0 +1,226 @@ +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} + + ) +} diff --git a/web/src/lib/config.ts b/web/src/lib/config.ts new file mode 100644 index 0000000000000000000000000000000000000000..4cb9e06cb126af47dfb2b0e60914cdc95cd3bfe5 --- /dev/null +++ b/web/src/lib/config.ts @@ -0,0 +1,39 @@ +// config.ts — reads OIDC configuration injected by the Go server into +// window.__LETHE_CONFIG__ (IF1). The injected JSON uses snake_case field names +// (Go struct json tags); this module converts to camelCase for ergonomic use +// throughout the SPA. + +export interface LetheConfig { + issuer: string + clientId: string +} + +// Raw shape as injected by the Go server (json tags: issuer, client_id). +interface RawLetheConfig { + issuer: string + client_id: string +} + +declare global { + interface Window { + __LETHE_CONFIG__?: RawLetheConfig + } +} + +/** + * Read the OIDC configuration from window.__LETHE_CONFIG__. + * + * Throws Error('lethe config missing') if the property is absent — fail-fast + * per GPC6. The caller (auth provider mount) should catch this and render an + * "auth-config missing" error card rather than swallowing the exception. + */ +export function readConfig(): LetheConfig { + const raw = window.__LETHE_CONFIG__ + if (raw == null) { + throw new Error('lethe config missing') + } + return { + issuer: raw.issuer, + clientId: raw.client_id, + } +} diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index ee5b119177693888188feb82954bf5e35fdb8e21..daab432252e8daf75da1a531ca0d192e21f03f57 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -13,9 +13,11 @@ import { Route as StatsRouteImport } from './routes/stats' import { Route as SettingsRouteImport } from './routes/settings' import { Route as SearchRouteImport } from './routes/search' import { Route as ProjectsRouteImport } from './routes/projects' +import { Route as LoginRouteImport } from './routes/login' import { Route as HealthRouteImport } from './routes/health' import { Route as IndexRouteImport } from './routes/index' import { Route as ProjectSplatRouteImport } from './routes/project.$' +import { Route as AuthCallbackRouteImport } from './routes/auth.callback' import { Route as SessionToolHostIdRouteImport } from './routes/session.$tool.$host.$id' const StatsRoute = StatsRouteImport.update({ @@ -38,6 +40,11 @@ const ProjectsRoute = ProjectsRouteImport.update({ path: '/projects', getParentRoute: () => rootRouteImport, } as any) +const LoginRoute = LoginRouteImport.update({ + id: '/login', + path: '/login', + getParentRoute: () => rootRouteImport, +} as any) const HealthRoute = HealthRouteImport.update({ id: '/health', path: '/health', @@ -53,6 +60,11 @@ const ProjectSplatRoute = ProjectSplatRouteImport.update({ path: '/project/$', getParentRoute: () => rootRouteImport, } as any) +const AuthCallbackRoute = AuthCallbackRouteImport.update({ + id: '/auth/callback', + path: '/auth/callback', + getParentRoute: () => rootRouteImport, +} as any) const SessionToolHostIdRoute = SessionToolHostIdRouteImport.update({ id: '/session/$tool/$host/$id', path: '/session/$tool/$host/$id', @@ -62,20 +74,24 @@ const SessionToolHostIdRoute = SessionToolHostIdRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/health': typeof HealthRoute + '/login': typeof LoginRoute '/projects': typeof ProjectsRoute '/search': typeof SearchRoute '/settings': typeof SettingsRoute '/stats': typeof StatsRoute + '/auth/callback': typeof AuthCallbackRoute '/project/$': typeof ProjectSplatRoute '/session/$tool/$host/$id': typeof SessionToolHostIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/health': typeof HealthRoute + '/login': typeof LoginRoute '/projects': typeof ProjectsRoute '/search': typeof SearchRoute '/settings': typeof SettingsRoute '/stats': typeof StatsRoute + '/auth/callback': typeof AuthCallbackRoute '/project/$': typeof ProjectSplatRoute '/session/$tool/$host/$id': typeof SessionToolHostIdRoute } @@ -83,10 +99,12 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/health': typeof HealthRoute + '/login': typeof LoginRoute '/projects': typeof ProjectsRoute '/search': typeof SearchRoute '/settings': typeof SettingsRoute '/stats': typeof StatsRoute + '/auth/callback': typeof AuthCallbackRoute '/project/$': typeof ProjectSplatRoute '/session/$tool/$host/$id': typeof SessionToolHostIdRoute } @@ -95,30 +113,36 @@ export interface FileRouteTypes { fullPaths: | '/' | '/health' + | '/login' | '/projects' | '/search' | '/settings' | '/stats' + | '/auth/callback' | '/project/$' | '/session/$tool/$host/$id' fileRoutesByTo: FileRoutesByTo to: | '/' | '/health' + | '/login' | '/projects' | '/search' | '/settings' | '/stats' + | '/auth/callback' | '/project/$' | '/session/$tool/$host/$id' id: | '__root__' | '/' | '/health' + | '/login' | '/projects' | '/search' | '/settings' | '/stats' + | '/auth/callback' | '/project/$' | '/session/$tool/$host/$id' fileRoutesById: FileRoutesById @@ -126,10 +150,12 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute HealthRoute: typeof HealthRoute + LoginRoute: typeof LoginRoute ProjectsRoute: typeof ProjectsRoute SearchRoute: typeof SearchRoute SettingsRoute: typeof SettingsRoute StatsRoute: typeof StatsRoute + AuthCallbackRoute: typeof AuthCallbackRoute ProjectSplatRoute: typeof ProjectSplatRoute SessionToolHostIdRoute: typeof SessionToolHostIdRoute } @@ -164,6 +190,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProjectsRouteImport parentRoute: typeof rootRouteImport } + '/login': { + id: '/login' + path: '/login' + fullPath: '/login' + preLoaderRoute: typeof LoginRouteImport + parentRoute: typeof rootRouteImport + } '/health': { id: '/health' path: '/health' @@ -185,6 +218,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProjectSplatRouteImport parentRoute: typeof rootRouteImport } + '/auth/callback': { + id: '/auth/callback' + path: '/auth/callback' + fullPath: '/auth/callback' + preLoaderRoute: typeof AuthCallbackRouteImport + parentRoute: typeof rootRouteImport + } '/session/$tool/$host/$id': { id: '/session/$tool/$host/$id' path: '/session/$tool/$host/$id' @@ -198,10 +238,12 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, HealthRoute: HealthRoute, + LoginRoute: LoginRoute, ProjectsRoute: ProjectsRoute, SearchRoute: SearchRoute, SettingsRoute: SettingsRoute, StatsRoute: StatsRoute, + AuthCallbackRoute: AuthCallbackRoute, ProjectSplatRoute: ProjectSplatRoute, SessionToolHostIdRoute: SessionToolHostIdRoute, } diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index 0033910c33f6d0e3261d77b8b582a86a2d45641e..6554cf0636dbf5b30cfbcc67d236b04a1ca9c626 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -5,6 +5,7 @@ import { createKeyboardController } from '../lib/keyboard' import type { RouteName } from '../lib/keyboard' import { TopBar } from '../shell/TopBar' import { Palette } from '../shell/Palette' +import { AuthProvider } from '../lib/authContext' import '../styles/tokens.css' import '../styles/primitives.css' import '../styles/shell.css' @@ -78,12 +79,14 @@ function RootComponent(): React.JSX.Element { }, []) // navigate is stable; intentional empty-dep mount effect return ( - -
- setPaletteOpen(true)} /> - - setPaletteOpen(false)} /> -
-
+ + +
+ setPaletteOpen(true)} /> + + setPaletteOpen(false)} /> +
+
+
) } diff --git a/web/src/routes/auth.callback.tsx b/web/src/routes/auth.callback.tsx new file mode 100644 index 0000000000000000000000000000000000000000..16a29c3558308566467f186224135188e020f496 --- /dev/null +++ b/web/src/routes/auth.callback.tsx @@ -0,0 +1,246 @@ +// auth.callback.tsx — handles the OIDC authorization code callback. +// +// NOTE: This file uses raw `fetch` to POST to the OP token endpoint. +// This is the ONE allowed exception to the invariant "no raw fetch outside +// client.ts": the OP /token endpoint is a cross-origin request to the issuer +// host and is not part of the /api/v1/* surface that apiFetch serves. + +import React, { useEffect, useRef } from 'react' +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { tokenStore, countCallbackFailures } from '../lib/auth' +import { useAuth } from '../lib/authContext' +import { readConfig } from '../lib/config' + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface PendingAuth { + verifier: string + state: string + returnTo: string + expiresAt: number +} + +const PENDING_KEY = 'lethe_auth_pending' +const FAILURES_KEY = 'lethe_auth_failures' + +// ── Route ───────────────────────────────────────────────────────────────────── + +export const Route = createFileRoute('/auth/callback')({ + validateSearch: (s: Record) => ({ + code: typeof s['code'] === 'string' ? s['code'] : undefined, + state: typeof s['state'] === 'string' ? s['state'] : undefined, + error: typeof s['error'] === 'string' ? s['error'] : undefined, + error_description: typeof s['error_description'] === 'string' ? s['error_description'] : undefined, + }), + component: CallbackRoute, +}) + +// ── Component ───────────────────────────────────────────────────────────────── + +function CallbackRoute(): React.JSX.Element { + const navigate = useNavigate() + const { reportAuthError } = useAuth() + const search = Route.useSearch() + + // Prevent double-execution in React StrictMode double-invoke. + const ranRef = useRef(false) + + // blocked state: once set, render "please reload" card with no retry. + const [blocked, setBlocked] = React.useState(false) + const [authError, setAuthError] = React.useState(null) + + useEffect(() => { + if (ranRef.current) return + ranRef.current = true + + void (async () => { + // ── Helper: record a failure and check the anti-loop guard ────────────── + function recordFailureAndCheck(): boolean { + const raw = localStorage.getItem(FAILURES_KEY) + let log: number[] = [] + try { + if (raw != null) { + log = JSON.parse(raw) as number[] + if (!Array.isArray(log)) log = [] + } + } catch { + log = [] + } + log.push(Date.now()) + localStorage.setItem(FAILURES_KEY, JSON.stringify(log)) + + const { blocked: isBlocked } = countCallbackFailures(Date.now(), log) + return isBlocked + } + + // ── Helper: fail with a message ───────────────────────────────────────── + function fail(msg: string): void { + const isBlocked = recordFailureAndCheck() + reportAuthError(msg) + setAuthError(msg) + if (isBlocked) { + setBlocked(true) + } + } + + // ── 0. Check for OP-reported error in the callback URL ────────────────── + if (search.error != null) { + const desc = search.error_description ?? search.error + fail(`Authorization error: ${desc}`) + return + } + + // ── 1. Validate that code and state are present ───────────────────────── + if (search.code == null || search.state == null) { + fail('Missing code or state in callback') + return + } + + // ── 2. Read and validate the pending auth entry from localStorage ──────── + const raw = localStorage.getItem(PENDING_KEY) + if (raw == null) { + fail('No pending authorization found (state may have expired)') + return + } + + let pending: PendingAuth + try { + pending = JSON.parse(raw) as PendingAuth + } catch { + fail('Corrupted pending authorization state') + return + } + + // State parameter must match. + if (pending.state !== search.state) { + fail('State mismatch — possible CSRF') + return + } + + // TTL check. + if (Date.now() > pending.expiresAt) { + fail('Authorization request expired — please try again') + return + } + + // Consume the pending entry immediately (single-use). + localStorage.removeItem(PENDING_KEY) + + // ── 3. Read config ────────────────────────────────────────────────────── + let cfg: ReturnType + try { + cfg = readConfig() + } catch (e) { + fail('Auth config missing during token exchange') + return + } + + // ── 4. Exchange code for tokens via POST to OP /token ─────────────────── + const origin = window.location.origin + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code: search.code, + code_verifier: pending.verifier, + redirect_uri: `${origin}/auth/callback`, + client_id: cfg.clientId, + }) + + let resp: Response + try { + resp = await fetch(`${cfg.issuer}/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + fail(`Token endpoint unreachable: ${msg}`) + return + } + + if (!resp.ok) { + let detail = resp.statusText + try { + const errBody = await resp.json() as Record + if (typeof errBody['error_description'] === 'string') { + detail = errBody['error_description'] + } else if (typeof errBody['error'] === 'string') { + detail = errBody['error'] + } + } catch { + // ignore JSON parse failure; use status text + } + fail(`Token exchange failed (${resp.status}): ${detail}`) + return + } + + // ── 5. Parse token response ───────────────────────────────────────────── + let tokenResp: Record + try { + tokenResp = await resp.json() as Record + } catch { + fail('Token endpoint returned invalid JSON') + return + } + + const accessToken = tokenResp['access_token'] + if (typeof accessToken !== 'string' || accessToken === '') { + fail('Token endpoint did not return an access_token') + return + } + + // ── 6. Store token and navigate to returnTo ───────────────────────────── + // Clear anti-loop failures log on success. + localStorage.removeItem(FAILURES_KEY) + + tokenStore.set(accessToken) + await navigate({ to: pending.returnTo }) + })() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) // mount-only + + // ── Render ────────────────────────────────────────────────────────────────── + + if (blocked) { + return ( +
+
+
sign-in blocked
+
+ Too many failed sign-in attempts. Please reload the page to try again. +
+
+
+ ) + } + + if (authError != null) { + return ( +
+
+
auth error
+
{authError}
+
+
+ ) + } + + // Default: processing the callback. + return ( +
+
+
completing sign-in…
+
Please wait.
+
+
+ ) +} diff --git a/web/src/routes/login.tsx b/web/src/routes/login.tsx new file mode 100644 index 0000000000000000000000000000000000000000..34a5722b6b51e8d74300166de6b208e069edd5ea --- /dev/null +++ b/web/src/routes/login.tsx @@ -0,0 +1,32 @@ +import React, { useEffect } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { useAuth } from '../lib/authContext' + +export const Route = createFileRoute('/login')({ + validateSearch: (s: Record) => ({ + return_to: typeof s['return_to'] === 'string' ? s['return_to'] : '/', + }), + component: LoginRoute, +}) + +function LoginRoute(): React.JSX.Element { + const { signIn } = useAuth() + const { return_to } = Route.useSearch() + + useEffect(() => { + signIn(return_to) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) // mount-only: trigger redirect once + + return ( +
+
+
redirecting…
+
Taking you to the sign-in page.
+
+
+ ) +}