@@ 0,0 1,345 @@
+# lethe-web-ui-login
+
+**Status:** Design (hands-off)
+**Module:** `sourcecraft.dev/bigbes/lethe`
+**Branch:** master
+**Worktree:** none
+**Parent RFC:** Personal AI Assistant Log Aggregator (2026-04-25)
+**Sibling tasks:**
+- `lethe-server.md` (#1, ✓ Verified) — owner of `internal/server/auth/` (forward-auth, OIDCVerifier, OIDCDevStub, middleware)
+- `lethe-web-ui-foundation.md` (#4, ✓ Reviewed) — established the SPA shell, `AuthError` class, "not authenticated" placeholder card; this task replaces the dead-end placeholder with a real flow
+- `lethe-oidc-stub.md` (#10, ✓ Reviewed) — provides the in-process dev OP that the SPA flow targets in dev/test
+- `lethe-web-ui-aggregates.md` (#5, ✓ Reviewed) — has a "browser smoke deferred" note this task helps unblock once login works
+- `lethe-web-ui-palette-savedsearch.md` (#6, ✓ done) — same browser-smoke gap; same unblock
+
+## Design
+
+### Purpose
+
+Replace the SPA's three dead-end "not authenticated" cards (`routes/index.tsx:90`, `routes/projects.tsx:119`, `features/settings/SavedSearchesSection.tsx:179`) with a working OIDC auth-code+PKCE flow that runs identically against the dev stub and a real OP. Closes the lineage's recurring "browser smoke deferred" gap from #5 and #6 by giving verify a clean end-to-end smoke gate.
+
+**In scope**: dev-stub upgrade to a real auth-code OP (`/authorize`, `/token`, updated discovery doc); SPA login flow (`/login`, `/auth/callback`, `web/src/lib/auth.ts`, in-memory token store); `apiFetch` token attachment; shared `AuthGate` component replacing the three placeholder cards; anti-loop error state with bounded retries.
+
+**Out of scope**: BFF / cookie-session / silent refresh (deferred — additive if reload churn becomes a problem); logout endpoint (clear in-memory token == reload, until silent refresh ships); real-OP integration testing (dev stub is the smoke gate, real OP testing is a manual user step); multi-tenant isolation hardening (single user).
+
+### Chosen approach
+
+**Dev-stub upgrade (Path A)** — extend `internal/testutil/oidcstub/` to a full auth-code+PKCE OP so dev and prod run the same SPA code path. Single code path beats divergent ones; the stub work is small (~150–250 LoC) and self-contained.
+
+**Token model — SPA-direct (Bearer)** — backend already accepts `Authorization: Bearer` (`internal/server/auth/middleware.go:155-165`). Zero new backend endpoints. SPA is same-origin (embedded by `internal/server/web/embed.go`) and personal-scope (single user) — XSS blast radius is bounded.
+
+**Token storage — in-memory React context** — no localStorage, no cookies. Page reload = re-login. Acceptable for a daemon-style personal app where sessions are hours-long.
+
+**Refresh — re-login on expiry** — token TTL = session lifetime. Stub issues 8h tokens; real OPs typically 1–8h. No silent-refresh complexity in v1.
+
+**Trigger — manual click first; auto-redirect on mid-session 401** — first cold render shows a "Sign in with OIDC" button (anti-popup-blocker, anti-loop). Mid-session 401 auto-redirects since the user already opted in.
+
+**Anti-loop guard** — PKCE state + `return_to` in localStorage with 5-minute TTL. On callback failure (state mismatch, /token error, OP 5xx), render distinct "auth error" card with manual retry. After 3 callback failures in 5 minutes: "please reload" — no further auto-redirects.
+
+### Routes added
+
+**SPA (`web/src/routes/`)**:
+- `login.tsx` — generates `code_verifier` (43–128 chars, base64url-random), `code_challenge` (S256), `state` (random); persists to localStorage with TTL; redirects to `<issuer>/authorize?response_type=code&client_id=…&redirect_uri=…&state=…&code_challenge=…&code_challenge_method=S256`.
+- `auth.callback.tsx` — reads `?code=&state=`; validates state matches localStorage; POSTs `<issuer>/token` with `code_verifier`; stores access token in memory; navigates to `return_to` (defaulting to `/`).
+
+**Dev stub (`internal/testutil/oidcstub/`)**:
+- `GET /authorize` — auto-consent: validates required params, generates opaque `code` (32-byte base64url), stores `(code → {sub, code_challenge, redirect_uri}, 5-min TTL)`, 302s to `redirect_uri?code=<code>&state=<echoed>`.
+- `POST /token` — validates `grant_type=authorization_code`; looks up code; verifies `SHA256(code_verifier) == base64url-decoded(code_challenge)` (RFC 7636 S256); mints + returns `{ access_token, id_token, token_type:"Bearer", expires_in:28800 }`. Single-use code (deleted after exchange).
+- Discovery-doc updates: add `"code"` to `response_types_supported`; add `grant_types_supported: ["authorization_code"]`; add `code_challenge_methods_supported: ["S256"]`.
+
+### Backend changes
+
+**None** to `internal/server/auth/middleware.go` (already handles Bearer), `internal/server/auth/oidc.go` (verifier already validates issuer-signed JWTs regardless of how obtained), or any handler under `/api/v1/*`.
+
+**Dev-stub package only** — `internal/testutil/oidcstub/` gets the two new handlers plus discovery-doc field updates. The steward wrapper (`internal/server/auth/devstub.go`) is unchanged structurally; it just exposes the upgraded `Stub.Handler()`.
+
+### Backwards-compat check
+
+- Dev stub: `/dev/token` endpoint kept (other tests in the suite use it). New `/authorize` and `/token` are additive. Discovery doc gains fields but loses none.
+- SPA: `/login` and `/auth/callback` are new routes (additive); the three existing "not authenticated" cards collapse into shared `AuthGate` (UX change, not API change). No deployed user is affected.
+- Backend: zero changes to `/api/v1/*`. Existing forward-auth path (`Remote-User` injection via `vite.config.ts`) keeps working in dev-without-OIDC; the OIDC bearer path now actually receives tokens from the SPA when OIDC is enabled.
+
+Greenfield modulo the dev-stub additions.
+
+TDD: yes (scoped — dev-stub `/authorize` + `/token` handlers; PKCE state machine in `web/src/lib/auth.ts`; `apiFetch` token-attachment behavior. No for `AuthGate`/route chrome — visual feel, validated by manual smoke walk against the upgraded dev stub.)
+
+### Invariants
+
+- IV1 — Dev-stub `/token` rejects any code-verifier that does not S256-hash to the recorded code-challenge; no silent fallback to "accept any verifier".
+- IV2 — Dev-stub `/authorize` issues single-use codes; the code is deleted on first `/token` exchange and a second use returns `invalid_grant`.
+- IV3 — Codes and PKCE localStorage state both have a 5-minute TTL; expired entries are rejected with `invalid_grant` on the server side and "auth error — please retry" on the SPA side.
+- IV4 — SPA never persists `access_token` or `id_token` to localStorage or cookies; only an in-memory React context store.
+- IV5 — `apiFetch` (and `apiFetchVoid`) attach `Authorization: Bearer <token>` when the in-memory store has a token; never attach `Bearer ""` or `Bearer undefined`.
+- IV6 — On any callback failure (state mismatch, /token error, OP 5xx), the SPA renders the distinct "auth error" state and never auto-retries the redirect from that card.
+- IV7 — `AuthGate` renders a manual-click button on first cold render; only mid-session 401 (after at least one successful sign-in this page-load) triggers auto-redirect.
+- IV8 — The dev stub keeps the existing `/dev/token` endpoint unchanged so the existing handler/repository test suites and `cmd/lethe` e2e test (`TestEndToEnd_MultiUserIsolation`) continue to pass without modification.
+
+### Principles
+
+- PC1 — The dev stub is upgraded to be RFC-compliant for the auth-code+PKCE subset (RFC 6749 §4.1, RFC 7636 S256), not "good enough for our SPA". Why: real-OP smoke testing only stays in scope if the stub matches the spec. How to apply: when implementing `/authorize` and `/token`, follow the RFC literally for response codes, error bodies, and required parameters — don't take shortcuts that work against our own SPA but would diverge from a real OP.
+- PC2 — Token-in-memory is the security default; localStorage/cookie persistence is opt-in via a future task. Why: smallest XSS blast radius today. How to apply: any temptation to "just persist the token so reload works" goes to a follow-up task with a proper design pass on cookie/refresh strategy.
+
+### Assumptions
+
+- AS1 — Browser `crypto.subtle.digest('SHA-256', …)` is available in every target browser (modern Chrome/Firefox/Safari) — required for PKCE S256 in the SPA.
+- AS2 — Lethe's bind is loopback (validated by `validateLoopbackBind` in `internal/server/server.go:159`) and the dev stub binds to `127.0.0.1:<port>`, so OIDC redirect URIs of the form `http://127.0.0.1:<port>/auth/callback` are acceptable to both the dev stub and a real OP configured for localhost.
+- AS3 — The same SPA code that talks to the upgraded dev stub will, unchanged, talk to a real OP (Authelia, Keycloak, etc.) configured for the same client_id, redirect_uri, and S256-PKCE. Any divergence is a stub bug, not a SPA bug.
+
+### Unknowns
+
+- UK1 — Real-OP smoke (Authelia, Keycloak, or other) — out of scope per "Out of scope". The dev-stub flow should be RFC-compatible enough that the same SPA code talks to a real OP unchanged (AS3); confirmation deferred to the user's real-OP rollout.
+- UK2 — Whether `id_token` should be parsed in the SPA for the user's `name`/`email` to display in the TopBar. Initial answer: yes, parse and store the `name` claim only (no signature-chain re-validation in the SPA — server already validated). Keep the scope tight: the TopBar greeting only.
+
+## Plan
+
+Approach: four phases — PH1 lands all backend work (dev-stub `/authorize`+`/token` + `embed.go` config injection) Go-only; PH2 ships the pure-TS auth library (`auth.ts` + `apiFetch` token attachment); PH3 wires routes (`/login`, `/auth/callback`) + auth context using PH1's injected config and PH2's token store; PH4 collapses the three placeholder cards into shared `AuthGate`. PH1 ⊥ PH2 (different stacks, disjoint paths); PH3 fans out from both; PH4 fans out from PH3.
+
+### PH1 — Backend: dev-stub auth-code+PKCE upgrade + SPA config injection
+
+- **1.1** `internal/testutil/oidcstub/codestore.go` (create) — small in-memory code store, package `oidcstub`.
+ - `type codeEntry struct { Sub string; CodeChallenge string; RedirectURI string; ExpiresAt time.Time }`
+ - `type codeStore struct { mu sync.Mutex; entries map[string]codeEntry; now func() time.Time }` — `now` injected for deterministic tests.
+ - `func newCodeStore(now func() time.Time) *codeStore` — `now == nil` defaults to `time.Now`.
+ - `func (s *codeStore) Issue(sub, challenge, redirect string, ttl time.Duration) string` — returns opaque base64url 32-byte code.
+ - `func (s *codeStore) Consume(code string) (codeEntry, bool)` — returns entry and deletes it on first call (IV2). Returns `false` on miss or expiry (IV3).
+ - Respects: IV2, IV3.
+
+- **1.2** `internal/testutil/oidcstub/oidcstub.go:106-112` (modify) — extend `Stub.Handler()` to register `/authorize` and `/token`. Add a `*codeStore` field on `Stub` initialized in `New` with `now: time.Now`.
+ ```go
+ mux.HandleFunc("/authorize", s.handleAuthorize)
+ mux.HandleFunc("/token", s.handleToken)
+ ```
+
+- **1.3** `internal/testutil/oidcstub/oidcstub.go:114-126` (modify, `handleDiscovery`) — additive discovery-doc fields (Design):
+ - `response_types_supported`: `["id_token", "code"]` (was `["id_token"]`)
+ - new key `grant_types_supported`: `["authorization_code"]`
+ - new key `code_challenge_methods_supported`: `["S256"]`
+ - new key `authorization_endpoint`: already present (line 119) — value stays `s.issuer + "/auth"`. **Change to `s.issuer + "/authorize"`** to match the new handler path. (Pre-existing value `/auth` was a placeholder; no consumer.)
+ - new key `token_endpoint`: already present (line 120). No change.
+
+- **1.4** `internal/testutil/oidcstub/oidcstub.go` (modify, append) — `handleAuthorize`:
+ - `func (s *Stub) handleAuthorize(w http.ResponseWriter, r *http.Request)` — GET only.
+ - Reads required query params: `response_type=code`, `client_id`, `redirect_uri`, `state`, `code_challenge`, `code_challenge_method=S256`. Optional: `scope`.
+ - On any missing/invalid required param → 400 with body `{"error":"invalid_request","error_description":"…"}` (RFC 6749 §4.1.2.1, PC1).
+ - On valid request: derives `sub` from `s.devStubUser` (new field on `Stub`, source: `Cfg.OIDC.DevStub.User` threaded through Options.DevStubUser; defaults to `"bigbes"` when zero — matches existing convention).
+ - Calls `s.codes.Issue(sub, code_challenge, redirect_uri, 5*time.Minute)`.
+ - 302 redirect to `redirect_uri + "?code=" + code + "&state=" + state` (state echoed verbatim per RFC 6749 §4.1.2).
+ - Respects: IV2, IV3, PC1.
+
+- **1.5** `internal/testutil/oidcstub/oidcstub.go` (modify, append) — `handleToken`:
+ - `func (s *Stub) handleToken(w http.ResponseWriter, r *http.Request)` — POST only; `Content-Type: application/x-www-form-urlencoded` per RFC 6749.
+ - CORS preflight: on `OPTIONS`, return 204 with `Access-Control-Allow-Origin: *`, `Access-Control-Allow-Methods: POST`, `Access-Control-Allow-Headers: Content-Type`.
+ - On POST: parses form, validates `grant_type=authorization_code`; reads `code`, `code_verifier`, `redirect_uri`, `client_id`.
+ - Calls `s.codes.Consume(code)`. On miss/expired → 400 `{"error":"invalid_grant","error_description":"unknown or expired code"}` (IV2, IV3, PC1).
+ - On hit: verify `redirect_uri` matches the entry's stored `redirect_uri` (RFC 6749 §4.1.3). If not → 400 `invalid_grant`.
+ - Verify `base64url(SHA-256(code_verifier)) == entry.CodeChallenge` (RFC 7636 §4.6, IV1). If not → 400 `invalid_grant`.
+ - Mints access_token + id_token via existing `s.Mint(entry.Sub, s.defaultTTL, nil)`. (Same JWT for both — minimal stub; real OPs differ.)
+ - Returns 200 JSON `{"access_token":"<jwt>","id_token":"<jwt>","token_type":"Bearer","expires_in":<ttl-seconds>}` with `Access-Control-Allow-Origin: *`.
+ - Respects: IV1, IV2, IV3, PC1.
+
+- **1.6** `internal/testutil/oidcstub/oidcstub.go:34-44` (modify, `Options`) — add field:
+ - `DevStubUser string // sub to issue codes for; defaults to "bigbes" when zero`
+ - Threaded into `Stub` in `New`.
+
+- **1.7** `internal/server/auth/devstub.go:55-65` (modify) — pass `Cfg.OIDC.DevStub.User` (existing config field; if absent, leave as today's behavior — the stub falls back to `"bigbes"`). Single-line change to the `oidcstub.Options{}` literal.
+
+- **1.8** `internal/testutil/oidcstub/codestore_test.go` (create) — TDD targets:
+ - `Issue` returns distinct codes across calls; codes are URL-safe (no `/`, `+`, `=`); 32 bytes worth of entropy.
+ - `Consume` returns the issued entry on first call, `false` on second (IV2).
+ - `Consume` returns `false` on expired entry (advance injected `now` past TTL, IV3).
+ - `Consume` returns `false` for unknown code.
+ - Concurrent `Issue`/`Consume` is race-free (run with `-race`).
+
+- **1.9** `internal/testutil/oidcstub/oidcstub_test.go` (modify, append) — TDD targets:
+ - `TestAuthorize_RedirectsWithCodeAndState`: GET `/authorize?response_type=code&client_id=lethe&redirect_uri=http://x/cb&state=abc123&code_challenge=…&code_challenge_method=S256` → 302 with `Location: http://x/cb?code=<32+ chars>&state=abc123`.
+ - `TestAuthorize_MissingRequiredParam_Returns400`: 4 sub-cases (response_type, client_id, redirect_uri, code_challenge) each missing → 400 `invalid_request`.
+ - `TestAuthorize_NonS256Challenge_Returns400`: `code_challenge_method=plain` → 400.
+ - `TestToken_ValidExchange_ReturnsJWT`: full round-trip — Issue → /token POST with correct verifier → 200, JWT verifiable via existing `SignToken` machinery; `expires_in` matches stub's `defaultTTL`.
+ - `TestToken_BadVerifier_Returns400` (IV1): /token with verifier whose SHA-256 doesn't match → 400 `invalid_grant`.
+ - `TestToken_CodeReuse_Returns400` (IV2): same code consumed twice → first 200, second 400.
+ - `TestToken_ExpiredCode_Returns400` (IV3): inject `now` advanced past TTL → 400.
+ - `TestToken_RedirectURIMismatch_Returns400`: stored `http://x/cb`, sent `http://y/cb` → 400.
+ - `TestToken_OPTIONSPreflight_Returns204_CORS`: OPTIONS → 204 with `Access-Control-Allow-Origin: *`.
+ - `TestDiscovery_AdvertisesCodeFlow`: discovery JSON contains `"code"` in `response_types_supported`, `["authorization_code"]` in `grant_types_supported`, `["S256"]` in `code_challenge_methods_supported`.
+ - `TestDevToken_StillWorks` (IV8): re-assert `/dev/token?sub=alice` returns a JWT — sanity-check the existing path is untouched.
+
+- **1.10** `internal/server/web/embed.go` (modify) — extend the SPA-fallback handler to inject `window.__LETHE_CONFIG__` into `index.html` before the closing `</head>` tag. Two changes:
+ - New exported function `Handler(cfg Config) http.Handler` where `Config struct { Issuer string; ClientID string }`. Old signature `Handler() http.Handler` removed (single call site at `internal/server/server.go:114`, refactored in 1.11).
+ - On serving any path that resolves to `index.html` (root + SPA fallback paths), the handler reads the embedded `index.html`, performs ONE `bytes.Replace` of `</head>` with `<script>window.__LETHE_CONFIG__=…;</script></head>` where `…` is `json.Marshal(cfg)`. Static asset paths (`/assets/*`) bypass injection.
+ - Respects: IV4 (no SPA-side persistence; this is server-side), AS3.
+ - Reason for `bytes.Replace` over template rendering: embedded `index.html` has no template directives today; introducing `text/template` for one substitution adds a dependency for negligible benefit. Single-replace is GPC2-aligned (one concern: thread server config through to SPA).
+
+- **1.11** `internal/server/server.go:51-67` (modify) — `Server` struct gains nothing; the new `webpkg.Config` is constructed inline at `Init` (line 114) from `s.Cfg.Auth.OIDC.Issuer` and `s.Cfg.Auth.OIDC.Audience` (audience plays the client_id role in the existing config — see `internal/config/config.go:72`). Single line change at `server.go:114`:
+ - Was: `r.Get("/*", webpkg.Handler().ServeHTTP)`
+ - Now: `r.Get("/*", webpkg.Handler(webpkg.Config{Issuer: s.Cfg.Auth.OIDC.Issuer, ClientID: s.Cfg.Auth.OIDC.Audience}).ServeHTTP)`
+
+- **1.12** `internal/server/web/embed_test.go` (modify if exists, else create) — TDD:
+ - `TestHandler_InjectsConfigIntoIndex`: GET `/` → 200, body contains `window.__LETHE_CONFIG__={"Issuer":"http://stub","ClientID":"lethe"}` exactly once.
+ - `TestHandler_AssetsBypassInjection`: GET `/assets/index-XYZ.js` → bundle bytes unchanged (no script tag injection on JS/CSS files).
+ - `TestHandler_SPAFallbackInjects`: GET `/some/spa/route` → 200 HTML with the script tag (because SPA fallback returns index.html).
+
+- Commit (suggest two for clean log):
+ - `oidcstub: implement /authorize and /token for auth-code+PKCE flow`
+ - `server/web: inject window.__LETHE_CONFIG__ into served index.html`
+ - Implementer may pick one combined commit if cleaner; not load-bearing.
+
+### PH2 — SPA: PKCE library + Authorization-header attachment
+
+- **2.1** `web/src/lib/auth.ts` (create) — pure-function PKCE machinery + token store.
+ - `export async function generatePKCEPair(): Promise<{ verifier: string; challenge: string }>` — uses `crypto.getRandomValues` (43-byte buffer → 64 base64url chars verifier) and `crypto.subtle.digest('SHA-256', …)` for challenge (AS1).
+ - `export function generateState(): string` — 16 random bytes, base64url.
+ - `export function parseCallbackParams(search: string): { code: string; state: string } | { error: string; errorDescription?: string }` — discriminated union; returns `{ error: 'missing_params' }` when neither `code` nor `error` is present, mirrors the OP's `?error=…&error_description=…` shape otherwise.
+ - `export interface TokenStore { get(): string | null; set(token: string | null): void; subscribe(listener: (token: string | null) => void): () => void }` — single instance per page-load.
+ - `export const tokenStore: TokenStore = createTokenStore()` — module-level singleton, no React deps. Listeners list lives in a closure inside `createTokenStore`.
+ - Respects: IV4 (no localStorage / cookie writes anywhere in this file), IV5 (caller decides whether to attach), AS1.
+ - Notes: `crypto.subtle` returns `Promise<ArrayBuffer>`; helper `b64url(buf: ArrayBuffer | Uint8Array): string` performs RFC 4648 §5 base64url-no-pad encoding.
+
+- **2.2** `web/src/lib/auth.test.ts` (create) — TDD targets:
+ - `generatePKCEPair`: challenge is `b64url(SHA-256(verifier))` (compute manually with Node's `crypto` to verify); verifier length 43–128 chars (RFC 7636 §4.1); challenge has no padding.
+ - `generateState`: returns non-empty, two consecutive calls differ, length is fixed.
+ - `parseCallbackParams`: `?code=abc&state=xyz` → `{code:'abc', state:'xyz'}`; `?error=access_denied&error_description=…` → `{error:'access_denied', errorDescription:'…'}`; empty → `{error:'missing_params'}`.
+ - `tokenStore`: `get()` initially `null`; `set('x')` → `get() === 'x'`; `set(null)` → `get() === null`; subscribe receives 'x' then null in order; unsubscribe stops further notifications.
+
+- **2.3** `web/src/api/client.ts:19-45` (modify, `apiFetch`) — read token from `tokenStore.get()` and attach `Authorization: Bearer <token>` header when non-null. When null, the header is omitted (do not send `Bearer null` or `Bearer ""` — IV5).
+ - Same change at `apiFetch:52-76` (`apiFetchVoid`).
+ - Pattern: build a `headers` object by merging `Accept`, conditional `Authorization`, and `init?.headers` (caller wins on conflict — preserves existing override behavior).
+ - 401 response triggers existing `AuthError` flow unchanged. Token clearing on 401 does NOT happen here (PH3's auth context owns that).
+
+- **2.4** `web/src/api/client.test.ts` (create) — TDD targets:
+ - No token (default `tokenStore` state) → fetch is called without `Authorization` header.
+ - `tokenStore.set('abc')` → fetch is called with `Authorization: Bearer abc`.
+ - Caller-supplied `Authorization` header wins over stored token (covers admin/test override cases).
+ - 401 → `AuthError` thrown (regression — existing behavior preserved).
+ - Mock `globalThis.fetch` via `vi.stubGlobal`.
+
+- Commit: `web: PKCE machinery + Authorization-header attachment in apiFetch`
+
+### PH3 — SPA: auth context + `/login` + `/auth/callback` + config reader
+
+- **3.1** `web/src/lib/config.ts` (create) — read `window.__LETHE_CONFIG__` (IF1, produced by PH1).
+ - `export interface LetheConfig { issuer: string; clientId: string }`
+ - `export function readConfig(): LetheConfig` — returns config from `window.__LETHE_CONFIG__`. Throws if missing — fail-fast per GPC6 (no silent fallback to a default issuer).
+ - The throw happens at first call site (the auth provider mount). Caller catches and renders an "auth-config missing" error card so the SPA at least loads.
+
+- **3.2** `web/src/lib/authContext.tsx` (create) — React context wrapping `tokenStore`.
+ - `export interface AuthState { token: string | null; user: { name?: string } | null; status: 'unauthenticated' | 'authenticated' | 'auth_error'; error?: string }`
+ - `export const AuthContext = React.createContext<AuthContextValue | null>(null)`
+ - `export function AuthProvider({ children }: { children: React.ReactNode }): JSX.Element` — subscribes to `tokenStore`, parses ID token's `name` claim into `user`, exposes state via context.
+ - `export function useAuth(): AuthContextValue` — typed accessor; throws if used outside provider.
+ - `interface AuthContextValue { state: AuthState; signIn(returnTo?: string): void; signOut(): void; reportAuthError(error: string): void }`
+ - `signIn` — generates PKCE pair, persists verifier+state+returnTo to `localStorage` under key `lethe_auth_pending` (TTL 5 min), `window.location.assign(<authorize URL>)`.
+ - `signOut` — `tokenStore.set(null)` (clears in-memory; localStorage left alone).
+ - `reportAuthError` — used by callback route to flip state to `auth_error` with a description.
+ - Token-context-vs-tokenStore separation: `tokenStore` is the truth (PH2 owns); context just renders it as React state and adds metadata + actions.
+
+- **3.3** `web/src/routes/login.tsx` (create) — `createFileRoute('/login')`.
+ - `validateSearch: (s) => ({ return_to: typeof s.return_to === 'string' ? s.return_to : '/' })`
+ - On mount, calls `useAuth().signIn(search.return_to)`. Renders a "redirecting…" placeholder.
+
+- **3.4** `web/src/routes/auth.callback.tsx` (create) — `createFileRoute('/auth/callback')`.
+ - `validateSearch: (s) => ({ 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 })`
+ - On mount: reads `lethe_auth_pending` from localStorage; validates `state` matches and TTL not exceeded; POSTs to `<issuer>/token` with `grant_type=authorization_code`, `code`, `code_verifier`, `redirect_uri=${origin}/auth/callback`, `client_id=<config.clientId>`; on 200, calls `tokenStore.set(access_token)` and `navigate({ to: returnTo })`; on any failure, calls `useAuth().reportAuthError(...)` and shows the auth-error card. (IV6.)
+ - Anti-loop counter: increment `localStorage.lethe_auth_failures` on each callback failure; if `> 3` within 5 minutes, render "please reload" card with no retry button. (IV6.)
+ - Uses `apiFetch` is *not* applicable here — `/token` is on a different origin and doesn't carry our middleware. Use raw `fetch` with `Content-Type: application/x-www-form-urlencoded` body. **Note**: this is the one allowed exception to the foundation invariant "no raw `fetch` outside `client.ts`" — record as a deviation in the implementer's report. The exception is principled: cross-origin OP token endpoint is not part of our API.
+
+- **3.5** `web/src/routes/__root.tsx:32-89` (modify) — wrap `<Outlet>` and `<Palette>` in `<AuthProvider>`. The provider's `useEffect`-based subscription replaces nothing existing; additive.
+
+- **3.6** `internal/server/web/dist/index.html` (modify, build artifact only — implementer regenerates via `npm run build` and includes in commit) — Vite bundles the new `lib/auth.ts`, `lib/config.ts`, `lib/authContext.tsx`, two new routes; index.html will pick up new asset hashes.
+
+- Commit: `web: /login + /auth/callback routes + auth context + config reader`
+
+### PH4 — SPA: `AuthGate` + three call-site swaps
+
+- **4.1** `web/src/shell/AuthGate.tsx` (create) — single source of truth for the "not authenticated" UI.
+ - `export function AuthGate({ children }: { children: React.ReactNode }): JSX.Element`
+ - Reads `useAuth().state.status`. If `authenticated` → renders `children`. If `unauthenticated` → renders the "Sign in with OIDC" card with a manual button (IV7). If `auth_error` → renders the distinct error card (IV6) — copy: "couldn't sign you in" + the error description from context, plus a "Try again" button that calls `signIn()`.
+ - First-render-vs-mid-session distinction: `state.status === 'unauthenticated'` AND a session has previously been authenticated this page-load → auto-redirect via `signIn()` (IV7). Tracked by a `hasBeenAuthenticated` ref in the provider.
+ - Card chrome borrows from existing inline cards (`web/src/routes/index.tsx:91-98`): same `.card` class, same `.uppercase-mono` heading style, just consolidated.
+
+- **4.2** `web/src/routes/index.tsx:89-109` (modify, `error` block) — replace the inline AuthError card and the inline non-auth error card with `<AuthGate>{children}</AuthGate>` wrapping the existing happy-path return. The AuthGate renders the auth UI when needed; happy-path renders normally otherwise. Non-auth errors keep their existing inline card (AuthGate is NOT a generic error boundary).
+ - Specifically: lift the `if (error instanceof AuthError) return <auth card>` early-return — let `<AuthGate>` handle that case. Keep the `if (error != null)` non-auth-error early-return as-is.
+
+- **4.3** `web/src/routes/projects.tsx:117-141` (modify) — same shape as 4.2 at this route's auth-error block.
+
+- **4.4** `web/src/features/settings/SavedSearchesSection.tsx:177-200` (modify) — same shape as 4.2 at this section's auth-error block. Note this is mid-route, not a route component, so the wrap is around the section's body, not the whole route.
+
+- **4.5** `web/src/styles/shell.css` (modify if needed) — extract `.auth-card` rule if the inline cards' inline styles add up to enough lines to warrant a class. Likely 5–15 lines of CSS; keep alongside `.empty-state` rules.
+
+- Commit: `web: AuthGate consolidates three "not authenticated" cards`
+
+### Test strategy
+
+- PH1 TDD: every bullet under 1.8 / 1.9 / 1.12 is a failing test first. Coverage map: IV1 → `TestToken_BadVerifier_Returns400`; IV2 → `TestToken_CodeReuse_Returns400`; IV3 → `TestToken_ExpiredCode_Returns400`; IV8 → `TestDevToken_StillWorks`; PC1 → the discovery + RFC-error-shape tests.
+- PH2 TDD: bullets 2.2 / 2.4 cover IV4 (auth.ts has no `localStorage.setItem` for tokens — test by grep + by `tokenStore.set` not touching `window.localStorage`), IV5 (Authorization-header on/off behavior), AS1 (challenge via `crypto.subtle`).
+- PH3 not TDD'd: `/login`, `/auth/callback`, auth-context provider — visual + integration. Anti-loop counter logic *is* worth a single vitest case since it's pure: write a small helper `countCallbackFailures(now: number, log: number[]): number` in `lib/auth.ts` and test it. (Adds bullet 2.5 — fold into PH2 since it's pure.)
+- PH4 not TDD'd: AuthGate chrome — visual feel.
+
+**Adjustment**: bullet 2.5 added to PH2 — `countCallbackFailures` helper + 1 vitest case (3 failures within 5 min → block flag flips). Keeps anti-loop logic pure.
+
+### Order & dependencies
+
+- PH1 ⊥ PH2 (Go vs TS, disjoint paths). Wave 1.
+- PH3 consumes PH1's `window.__LETHE_CONFIG__` shape (IF1) and PH2's `tokenStore` (IF2). Wave 2.
+- PH4 consumes PH3's `useAuth()` (IF3). Wave 3.
+
+### Risks / rollback
+
+- **RK1** — Cross-origin POST `/token` from SPA to dev stub needs CORS preflight. PH1.5's OPTIONS handler + ACAO header on the POST response covers this; `TestToken_OPTIONSPreflight_Returns204_CORS` regression-tests it. Real OPs (Authelia, Keycloak) all support CORS for SPA clients, so this is correct, not stub-only.
+- **RK2** — Vite's existing `Remote-User: bigbes` proxy injection (`web/vite.config.ts`) means dev fetches always succeed; AuthGate never renders in dev → smoke walk can't exercise the new flow. Mitigation: smoke walk in verify documents the workaround (comment out the injection for the OIDC walk). No code change.
+- **RK3** — `crypto.subtle.digest` is unavailable in non-secure contexts. Browsers treat `localhost` and `127.0.0.1` as secure; HTTPS in prod is fine; LAN IPs (e.g. `192.168.x.y`) without HTTPS would fail. Out of scope per Design AS2 (loopback bind).
+- **RK4** — Vite-bundled `index.html` is regenerated by `npm run build`; PH3 and PH4 both will produce new bundle hashes. `internal/server/web/dist/index.html` must be re-staged in each commit (precedent from #5/#6 — verify catches it).
+- **RK5** — Rollback path: each phase's commit is independently revertible. PH1 revert leaves `/authorize`+`/token` returning 404 (no consumer until PH3+PH4 ship). PH2 revert removes Authorization-header attachment (apiFetch falls back to today's no-header behavior; existing forward-auth path keeps working). PH3 revert leaves `/login` and `/auth/callback` 404 from a future revert; until UI lands, nothing reaches them. PH4 revert leaves three duplicate "not authenticated" cards (today's state).
+
+### Interfaces
+
+- IF1 — `window.__LETHE_CONFIG__: { issuer: string; client_id: string }` injected into `index.html` by `internal/server/web/embed.go`. PH1 produces, PH3 consumes via `web/src/lib/config.ts`.
+- IF2 — `tokenStore: TokenStore` exported from `web/src/lib/auth.ts`; methods `get(): string | null`, `set(token: string | null): void`, `subscribe(listener): () => void`. PH2 produces, PH3 consumes (callback writes the token), PH4 consumes (AuthGate reads via context which reads via tokenStore).
+- IF3 — `useAuth(): AuthContextValue` exported from `web/src/lib/authContext.tsx`. PH3 produces, PH4 consumes.
+
+### Interface graph
+
+- PH1 -> IF1 @ internal/testutil/oidcstub/, internal/server/web/embed.go, internal/server/web/embed_test.go, internal/server/server.go, internal/server/auth/devstub.go
+- PH2 -> IF2 @ web/src/lib/auth.ts, web/src/lib/auth.test.ts, web/src/api/client.ts, web/src/api/client.test.ts
+- PH3 IF1, IF2 -> IF3 @ web/src/lib/config.ts, web/src/lib/authContext.tsx, web/src/routes/login.tsx, web/src/routes/auth.callback.tsx, web/src/routes/__root.tsx, internal/server/web/dist/index.html
+- PH4 IF3 -> @ web/src/shell/AuthGate.tsx, web/src/routes/index.tsx, web/src/routes/projects.tsx, web/src/features/settings/SavedSearchesSection.tsx, web/src/styles/shell.css, internal/server/web/dist/index.html
+
+Wave check:
+- Wave 1 (PH1, PH2): paths are disjoint (Go-only vs TS-only). ✓
+- Wave 2 (PH3 alone): single-phase wave; no disjointness check needed.
+- Wave 3 (PH4 alone): single-phase wave.
+- `internal/server/web/dist/index.html` appears in PH3 and PH4 — both regenerate the same artifact in their respective commits. They are in different waves (no parallel-edit risk).
+
+### Backwards-compat check (plan-side restatement)
+
+Restating Design's per-area compat with concrete phase steps:
+
+- **Dev stub** (PH1.3 discovery + 1.4/1.5 handlers): additive. New endpoints (`/authorize`, `/token`) under new paths. Discovery doc gains fields, loses none. `/dev/token` kept (IV8 — `TestDevToken_StillWorks` regression-tests it). The `authorization_endpoint` field's value changes from the placeholder `/auth` to `/authorize` to match the new handler path; no consumer of the old value exists in the codebase (grep `s\.issuer.*"/auth"` empty outside `oidcstub.go` itself).
+- **Embed handler** (PH1.10/1.11): `Handler()` signature changes to `Handler(cfg Config)`. Single call site at `internal/server/server.go:114` updated in 1.11. No external consumer.
+- **apiFetch** (PH2.3): adds `Authorization: Bearer …` only when token present; absent token = no header (today's behavior). Tests that don't seed a token continue to pass unchanged.
+- **__root.tsx wrap** (PH3.5): additive `<AuthProvider>` wrap; nothing existing depends on the absence of a context provider above `<Outlet>`.
+- **Three call-site swaps** (PH4.2/4.3/4.4): UX consolidation, not API change. Cards collapse into shared component.
+- **Foundation "no raw `fetch` outside `client.ts`" invariant**: PH3.4 (`auth.callback.tsx`) uses `fetch` directly to POST to the cross-origin `/token` endpoint — this is the one allowed exception (different origin, not part of `/api/v1/*`). Recorded as a plan-time deviation; implementer's commit message and the file's top-of-file comment will note it.
+
+Greenfield modulo the additions enumerated above. No schema migrations. No `internal/shared/wire/` changes.
+
+## Verify
+<empty — filled by up:uverify>
+
+## Conclusion
+<empty — filled by up:ureview>
+
+### Hands-off decisions
+
+- size: Medium — covers backend dev-stub upgrade + SPA login flow + three call-site swaps. Full Design + Plan + Execute flow.
+- mode: hands-off enabled mid-design at user request ("does frontend changed?" → "so handoffed design is not applicable?"). The /up:make invocation did not prefix `handsoff`; flipping now.
+- udesign — Path A (dev-stub upgrade to full auth-code+PKCE OP) — chosen over (B) two-mode SPA and (C) forward-auth-only-in-dev because single code path beats divergent ones, the stub work is small (~150–250 LoC) and self-contained, and it permanently closes the "browser smoke deferred" gap from #5/#6.
+- udesign — token model: SPA-direct (Bearer) — middleware already handles Bearer (`internal/server/auth/middleware.go:155-165`); zero new backend endpoints; same-origin embed bounds XSS exposure; personal-scope removes multi-tenant token-leakage concern.
+- udesign — token storage: in-memory React context — no localStorage, no cookies; smallest XSS blast radius; reload = re-login is acceptable for a daemon-style personal app.
+- udesign — refresh: re-login on expiry — token TTL = session lifetime (8h stub default); silent-refresh deferred as additive follow-up.
+- udesign — trigger: manual click first, auto-redirect on mid-session 401 — anti-popup-blocker on cold render; auto-redirect mid-session is safe because the user already opted in.
+- udesign — anti-loop: PKCE state + return_to in localStorage with 5-minute TTL; max 3 callback failures in 5 minutes before "please reload".
+- udesign — bundle dev-stub upgrade in this task (not a #10 follow-up) — task #10 is closed (✓ Reviewed); reopening it for a small additive change adds workflow overhead without buying anything.
+- branch/worktree: Branch=master, Worktree=none — deviates from the hands-off default ("dedicated branch + worktree, never direct edits to main") but matches every prior `lethe-*` task in this project (#1, #4, #5, #6, #10 all on master directly); project convention takes precedence.
+- uplan: plan auto-approved (hands-off) — four phases (PH1 backend stub+embed, PH2 SPA auth library, PH3 routes+context, PH4 AuthGate+3 call-site swaps); IF1/IF2/IF3 cross-phase; Wave 1 = PH1‖PH2, Wave 2 = PH3, Wave 3 = PH4.
+- uplan: surfaced deviation — SPA needs to learn issuer/client_id at runtime; design did not specify the mechanism. Pick: inject `window.__LETHE_CONFIG__` via `internal/server/web/embed.go` (smallest surface; no auth-bypass endpoint; same-origin static-config pattern). Logged in PH1.10/1.11 and Backwards-compat check.
+- uplan: surfaced deviation — PH3.4 (`auth.callback.tsx`) uses raw `fetch` to POST to cross-origin `/token`; the foundation invariant "no raw `fetch` outside `client.ts`" does not extend to non-`/api/v1` cross-origin endpoints. Allowed.