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 flowlethe-oidc-stub.md (#10, ✓ Reviewed) — provides the in-process dev OP that the SPA flow targets in dev/testlethe-web-ui-aggregates.md (#5, ✓ Reviewed) — has a "browser smoke deferred" note this task helps unblock once login workslethe-web-ui-palette-savedsearch.md (#6, ✓ done) — same browser-smoke gap; same unblockReplace 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).
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.
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)."code" to response_types_supported; add grant_types_supported: ["authorization_code"]; add code_challenge_methods_supported: ["S256"].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().
/dev/token endpoint kept (other tests in the suite use it). New /authorize and /token are additive. Discovery doc gains fields but loses none./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./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.)
/token rejects any code-verifier that does not S256-hash to the recorded code-challenge; no silent fallback to "accept any verifier"./authorize issues single-use codes; the code is deleted on first /token exchange and a second use returns invalid_grant.invalid_grant on the server side and "auth error — please retry" on the SPA side.access_token or id_token to localStorage or cookies; only an in-memory React context store.apiFetch (and apiFetchVoid) attach Authorization: Bearer <token> when the in-memory store has a token; never attach Bearer "" or Bearer undefined.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./dev/token endpoint unchanged so the existing handler/repository test suites and cmd/lethe e2e test (TestEndToEnd_MultiUserIsolation) continue to pass without modification./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.crypto.subtle.digest('SHA-256', …) is available in every target browser (modern Chrome/Firefox/Safari) — required for PKCE S256 in the SPA.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.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.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.
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).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.
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"])grant_types_supported: ["authorization_code"]code_challenge_methods_supported: ["S256"]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.)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.response_type=code, client_id, redirect_uri, state, code_challenge, code_challenge_method=S256. Optional: scope.{"error":"invalid_request","error_description":"…"} (RFC 6749 §4.1.2.1, PC1).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).s.codes.Issue(sub, code_challenge, redirect_uri, 5*time.Minute).redirect_uri + "?code=" + code + "&state=" + state (state echoed verbatim per RFC 6749 §4.1.2).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.OPTIONS, return 204 with Access-Control-Allow-Origin: *, Access-Control-Allow-Methods: POST, Access-Control-Allow-Headers: Content-Type.grant_type=authorization_code; reads code, code_verifier, redirect_uri, client_id.s.codes.Consume(code). On miss/expired → 400 {"error":"invalid_grant","error_description":"unknown or expired code"} (IV2, IV3, PC1).redirect_uri matches the entry's stored redirect_uri (RFC 6749 §4.1.3). If not → 400 invalid_grant.base64url(SHA-256(code_verifier)) == entry.CodeChallenge (RFC 7636 §4.6, IV1). If not → 400 invalid_grant.s.Mint(entry.Sub, s.defaultTTL, nil). (Same JWT for both — minimal stub; real OPs differ.){"access_token":"<jwt>","id_token":"<jwt>","token_type":"Bearer","expires_in":<ttl-seconds>} with Access-Control-Allow-Origin: *.1.6 internal/testutil/oidcstub/oidcstub.go:34-44 (modify, Options) — add field:
DevStubUser string // sub to issue codes for; defaults to "bigbes" when zeroStub 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.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:
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).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.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:
r.Get("/*", webpkg.Handler().ServeHTTP)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 flowserver/web: inject window.__LETHE_CONFIG__ into served index.html2.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.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).
apiFetch:52-76 (apiFetchVoid).headers object by merging Accept, conditional Authorization, and init?.headers (caller wins on conflict — preserves existing override behavior).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:
tokenStore state) → fetch is called without Authorization header.tokenStore.set('abc') → fetch is called with Authorization: Bearer abc.Authorization header wins over stored token (covers admin/test override cases).AuthError thrown (regression — existing behavior preserved).globalThis.fetch via vi.stubGlobal.Commit: web: PKCE machinery + Authorization-header attachment in apiFetch
/login + /auth/callback + config reader3.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).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.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 : '/' })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 })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.)localStorage.lethe_auth_failures on each callback failure; if > 3 within 5 minutes, render "please reload" card with no retry button. (IV6.)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
AuthGate + three call-site swaps4.1 web/src/shell/AuthGate.tsx (create) — single source of truth for the "not authenticated" UI.
export function AuthGate({ children }: { children: React.ReactNode }): JSX.ElementuseAuth().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().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.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).
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
TestToken_BadVerifier_Returns400; IV2 → TestToken_CodeReuse_Returns400; IV3 → TestToken_ExpiredCode_Returns400; IV8 → TestDevToken_StillWorks; PC1 → the discovery + RFC-error-shape tests.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)./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.)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.
window.__LETHE_CONFIG__ shape (IF1) and PH2's tokenStore (IF2). Wave 2.useAuth() (IF3). Wave 3./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.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.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).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)./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).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.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).useAuth(): AuthContextValue exported from web/src/lib/authContext.tsx. PH3 produces, PH4 consumes.Wave check:
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).Restating Design's per-area compat with concrete phase steps:
/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).Handler() signature changes to Handler(cfg Config). Single call site at internal/server/server.go:114 updated in 1.11. No external consumer.Authorization: Bearer … only when token present; absent token = no header (today's behavior). Tests that don't seed a token continue to pass unchanged.<AuthProvider> wrap; nothing existing depends on the absence of a context provider above <Outlet>.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.
Result: passed
Positive:
go test ./internal/testutil/oidcstub/... -race -count=1 → ok (codestore + handler tests, ~22 cases)go test ./internal/server/web/... -count=1 → ok (3 embed-injection tests)go test ./internal/... -count=1 → ok (all packages)go test ./cmd/lethe/... -count=1 → ok (RK4: e2e steward graph still works)go build ./... → cleancd web && npm test -- --run → 84 passed across 6 filescd web && npm run build → 382 modules, 499.94 KB JS / 152.73 KB gzip; dist/index.html matches tracked artifactNegative:
TestToken_BadVerifier_Returns400 → 400 invalid_grantTestToken_CodeReuse_Returns400 → first 200, second 400TestCodeStore_Consume_Expired → expired entry returns false. Note: TestToken_ExpiredCode_Returns400 is t.Skip'd because handleAuthorize uses 5*time.Minute against time.Now (not injectable); IV3 is fully covered at the codestore unit-test layer with injected clock. Recorded as a deviation.TestAuthorize_MissingRequiredParam_Returns400 → 4 sub-cases (response_type, client_id, redirect_uri, code_challenge) → 400 invalid_requestInvariants / assumptions:
oidcstub.go:340-346 rejects code_challenge_method != S256 with 400 invalid_request; oidcstub.go:405-410 verifies base64url(SHA-256(verifier)) == code_challenge before mintcodestore.go:71 delete(s.entries, code) runs unconditionally on Consume (single-use enforced)oidcstub.go:350 issues codes with 5*time.Minute TTL; codestore.go:62-72 rejects expiredgrep -n "localStorage.setItem" web/src/lib/auth.ts → empty; tokenStore writes only to closure stateweb/src/api/client.ts:25-28 returns {Authorization: \Bearer ${token}`}only whentoken !== null`; null returns empty object spread (no header)AuthGate.tsx:32-58 auth_error branch renders error card with manual "Try again" button (onClick={() => signIn(...)}); no useEffect auto-retry from this branchAuthGate.tsx:22-26 useEffect auto-redirect fires only when status === 'unauthenticated' && hasBeenAuthenticated === true; cold render (hasBeenAuthenticated === false) falls through to manual button at line 65+oidcstub.go:125 mux.HandleFunc("/dev/token", s.handleDevToken) unchanged; TestDevToken_StillWorks regression-tests itweb/src/test-setup.ts:3-11 polyfills globalThis.crypto from Node webcrypto in vitest; production browsers carry crypto.subtle natively per modern-browser baselineinternal/server/server.go keeps validateLoopbackBind; dev-stub binds to 127.0.0.1:<port>; redirect URIs of the form http://127.0.0.1:<port>/auth/callback are accepted by the stub (verified via TestAuthorize_RedirectsWithCodeAndState)Interfaces:
embed.go:25-28 Config{Issuer\json:"issuer"`, ClientID`json:"client_id"`}produceswindow.LETHE_CONFIG={"issuer":"…","client_id":"…"}; web/src/lib/config.ts:36-37consumes with the same key names;TestHandler_InjectsConfigIntoIndex` asserts the JSON shapetokenStore exported at web/src/lib/auth.ts:115, consumed at web/src/api/client.ts:26, web/src/lib/authContext.tsx:94,126,189, web/src/routes/auth.callback.tsx:196 — five call sites totaluseAuth() exported at web/src/lib/authContext.tsx:64, consumed at web/src/shell/AuthGate.tsx:17, web/src/routes/login.tsx:13, web/src/routes/auth.callback.tsx:42 — three call sitesNotes:
/login and /auth/callback routes plus the dev-stub /authorize+/token round-trip were not exercised end-to-end in a real browser. tsc + vitest + Go tests are clean and the dev stub is now RFC-compliant for auth-code+PKCE per the test suite, but a full Chrome walk requires standing up the lethe server with OIDC enabled, the dev stub, AND removing the Remote-User injection from web/vite.config.ts (RK2: otherwise dev fetches succeed via forward-auth and AuthGate never triggers). Out of reach in autonomous mode without user setup. Recommended walk: (a) start the lethe server with auth.oidc.enabled=true and auth.oidc.dev_stub.enabled=true, (b) comment out the Remote-User injection in web/vite.config.ts, (c) npm run dev, (d) hit /, see the AuthGate "Sign in with OIDC" card, click it, watch the round-trip, land back authenticated.Outcome: shipped — ac34df3..HEAD (HEAD = e920ae8); 7 execute commits + 3 review-fix commits + 2 doc commits.
Invariants:
oidcstub.go:340-346 rejects code_challenge_method != S256 with 400 invalid_request; oidcstub.go:405-410 verifies base64url(SHA-256(verifier)) == code_challenge before mint. Tests: TestToken_BadVerifier_Returns400, TestAuthorize_NonS256Challenge_Returns400.codestore.go:71 delete(s.entries, code) runs unconditionally inside Consume (single-use). Tests: TestCodeStore_Consume_SingleUse, TestToken_CodeReuse_Returns400.oidcstub.go:350 issues codes with 5*time.Minute TTL; codestore.go:62-72 rejects expired entries. Tests: TestCodeStore_Consume_Expired covers the unit-layer expiry; TestToken_ExpiredCode_Returns400 is t.Skip'd (handler clock not injectable — unit-test layer is the test boundary; recorded as deviation).grep -n "localStorage.setItem" web/src/lib/auth.ts empty; tokenStore writes are closure-only. Anti-loop counter and PKCE state in localStorage are timestamps + verifiers + state nonces, never tokens. Cache-invalidation test in auth.test.ts enforces.web/src/api/client.ts:25-28 returns {Authorization: \Bearer ${token}`}only whentoken !== null; null returns empty spread (no header). Tests in client.test.ts` cover both branches.AuthGate.tsx:32-58 auth_error branch renders manual "Try again" button; no useEffect auto-retry. Anti-loop counter in auth.callback.tsx blocks at >3 failures within 5 min and renders "please reload" instead of redirecting.AuthGate.tsx:22-26 useEffect auto-redirect fires only when status === 'unauthenticated' && hasBeenAuthenticated === true; cold render falls through to manual button at line 65+. hasBeenAuthenticated flag flows from AuthProvider to AuthGate via AuthState.oidcstub.go:125 mux.HandleFunc("/dev/token", s.handleDevToken) unchanged; TestDevToken_StillWorks regression-passes; TestEndToEnd_MultiUserIsolation in cmd/lethe passes.web/src/test-setup.ts:3-11 polyfills globalThis.crypto from Node webcrypto for vitest; production browsers carry crypto.subtle natively per modern-browser baseline. No conditional exists in production code that depends on the polyfill.internal/server/server.go keeps validateLoopbackBind; dev-stub binds to 127.0.0.1:<port>; SPA's redirect_uri = ${origin}/auth/callback resolves to a localhost URL the stub accepts. TestAuthorize_RedirectsWithCodeAndState exercises a localhost redirect.authContext.tsx parses the id_token payload for the name claim only; falls back to user = null on parse failure (token still valid for middleware checks; name is cosmetic). Stored in AuthState.user.name; consumed nowhere yet (TopBar greeting not wired in this task — additive follow-up).Cfg.OIDC.DevStub.User to oidcstub.Options{}, but OIDCDevStubConfig has no User field in internal/config/config.go. PH1 implementer disclosed this; the stub falls back to its "bigbes" constant default. Behaviour matches the project's single-developer convention. Adding the config field is scope creep; logged here for completeness. Reviewer flagged separately at confidence 80; same call.Stub.handleAuthorize redirect_uri validation — RFC 6749 §4.1.3 says the redirect_uri sent on /token must match the one stored at /authorize. oidcstub.go:401-403 (in handleToken) verifies this; TestToken_RedirectURIMismatch_Returns400 regression-tests it. Plan called this out under PC1; verified.embed.go silent no-op when </head> absent — fixed in f1926dc by comparing pre/post bytes.Replace and writing 500 on no-op (fail-loud per GPC6).oidcstub.go handleAuthorize redirect query unencoded — fixed in be6e43e by switching to url.Values{}.Encode() for RFC 6749 §4.1.2 compliance (PC1). Operationally inert in today's SPA (state was URL-safe), but real-OP-parity invariant required the fix.auth.callback.tsx lethe_auth_failures array unbounded — fixed in e920ae8 by pruning entries outside the 5-min window before each push. Plan explicitly asked "is the array bounded; are timestamps pruned at insert" — answer is now yes.devstub.go plan 1.7 no-op — recorded only (see Plan adherence). Behavior is correct; only loss is configurability that was never in the config struct.AuthState.user.name into the TopBar greeting — UK2 resolved at the data layer; UI hookup deferred (out of scope per Design "TopBar greeting only" comment; not load-bearing for this task's gating-flow purpose). Single sibling-task call when desired.User field to OIDCDevStubConfig if a future contributor needs to dev as someone other than bigbes. Below the rule-of-three threshold today.Remote-User injection in web/vite.config.ts (RK2).handsoff; flipped after the user said "so handoffed design is not applicable?".lethe-* task.window.__LETHE_CONFIG__ via internal/server/web/embed.go.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.Config{Issuer, ClientID} had no json tags so JSON would have emitted PascalCase, breaking PH3's client_id reader. Fixed inline at the wave-1/wave-2 boundary in commit 5d910e8 before dispatching PH3.bytes.Equal check; 500 on missing </head> (commit f1926dc).url.Values.Encode() for RFC 6749 §4.1.2 compliance (commit be6e43e).e920ae8).handsoff; flipping now.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.lethe-* task in this project (#1, #4, #5, #6, #10 all on master directly); project convention takes precedence.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.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.