# 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 `/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 `/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=&state=`. - `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 ` 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:`, so OIDC redirect URIs of the form `http://127.0.0.1:/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":"","id_token":"","token_type":"Bearer","expires_in":}` 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 `` 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 `` with `` 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`; 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 ` 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(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()`. - `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 `/token` with `grant_type=authorization_code`, `code`, `code_verifier`, `redirect_uri=${origin}/auth/callback`, `client_id=`; 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 `` and `` in ``. 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 `{children}` 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 ` early-return — let `` 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 `` wrap; nothing existing depends on the absence of a context provider above ``. - **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 ## Conclusion ### 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.