@@ 0,0 1,257 @@
+# lethe-oidc-stub
+
+**Status:** Designed
+**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/`
+- `lethe-web-ui-aggregates.md` (#5, ✓ Reviewed) — has the deferred browser-smoke item this task unblocks
+
+## Design
+
+### Purpose
+
+Promote the in-test `oidcTestServer` helper (`internal/server/auth/oidctestserver_test.go`) to an exported `internal/testutil/oidcstub/` package, then wire that package into the `lethe` daemon as an opt-in dev-only stub OIDC OP. The integrated stub runs on its own loopback listener, mints ready-to-paste bearer tokens at startup, and exposes a `/dev/token` mint endpoint — closing the deferred browser-smoke item from `lethe-web-ui-aggregates` and unblocking real-browser walkthroughs of any future SPA route work without standing up Authelia locally.
+
+JWT-based by design: lethe is the OIDC verifier; JWT is the wire format OIDC mandates. The wiki cite `~/data/home/second-brain/wiki/jwt-for-sessions.md` is a guard against using JWTs *as session tokens* — unrelated.
+
+### Scope
+
+**In:**
+- New package `internal/testutil/oidcstub/` — exported, leaf, `*testing.T`-free.
+- New steward asset `authpkg.DevStub` (or `authpkg.OIDCDevStub`) registered in `cmd/lethe/main.go` only when `cfg.Auth.OIDC.DevStub.Enabled`.
+- New config block `auth.oidc.dev_stub` (additive — `enabled`, `bind`, optional `token_ttl`).
+- Startup banner: one bearer token per `allowed_users` entry, printed to stdout / a logger line.
+- HTTP route on the stub's listener: `GET /dev/token?sub=<user>&exp=<duration>` returning `{ "token": "...", "expires_at": <unix> }`.
+- Existing `internal/server/auth/middleware_test.go` re-imports the new package and deletes the duplicated helper; semantics unchanged.
+
+**Out:**
+- `cmd/oidc-stub` standalone binary (rejected in design dialogue — integration goes into the daemon instead).
+- Persisted signing keys across restarts (ephemeral RSA per process — see AS3).
+- Build-tag or env-var guards (config-only gate per explicit operator choice — see AS1).
+- Multi-issuer / key-rotation testing surface beyond what the existing helper already supports.
+
+### Chosen approach
+
+**Library piece (`internal/testutil/oidcstub/`):**
+
+- Lift the existing helper out of `_test.go`. New file `internal/testutil/oidcstub/oidcstub.go`.
+- Public API:
+ - `type Stub struct { ... }`
+ - `type Options struct { Issuer string; Audience string; UsernameClaim string; SigningKey *rsa.PrivateKey /*optional, generates if nil*/; KeyID string /*defaults to "oidcstub-key-1"*/ }`
+ - `func New(opts Options) (*Stub, error)` — constructs but does NOT bind a listener. Caller supplies issuer URL.
+ - `func (s *Stub) Handler() http.Handler` — returns an `http.ServeMux` registered for `/.well-known/openid-configuration`, `/jwks`, and (when daemon-integrated) `/dev/token`. Caller mounts it on whatever listener they want.
+ - `func (s *Stub) SignToken(claims map[string]any) (string, error)` — RS256, merges defaults (`iss`, `iat`, `exp = iat+1h`) under caller claims so caller wins.
+ - `func (s *Stub) Mint(sub string, ttl time.Duration, extra map[string]any) (string, time.Time, error)` — convenience for the `/dev/token` handler and the startup-banner code; sets `sub`, `iat`, `exp = iat + ttl`, merges `extra`.
+- No `*testing.T`. Errors return; cleanup is the caller's problem.
+- Re-targeted middleware test uses `httptest.NewServer(stub.Handler())` and a small adapter to keep the existing `signToken` call sites working.
+
+**Daemon-integration piece (`internal/server/auth/devstub.go` or sibling):**
+
+- Config additions in `internal/config/config.go`:
+ ```go
+ type OIDCConfig struct {
+ Enabled bool `mapstructure:"enabled"`
+ Issuer string `mapstructure:"issuer"`
+ Audience string `mapstructure:"audience"`
+ UsernameClaim string `mapstructure:"username_claim"`
+ DevStub OIDCDevStubConfig `mapstructure:"dev_stub"`
+ }
+ type OIDCDevStubConfig struct {
+ Enabled bool `mapstructure:"enabled"`
+ Bind string `mapstructure:"bind"` // e.g. "127.0.0.1:8181"
+ TokenTTL time.Duration `mapstructure:"token_ttl"` // default 24h if zero
+ }
+ ```
+- New asset `authpkg.OIDCDevStub`:
+ - Holds `Cfg config.AuthConfig` (so it can read both `oidc.*` and `allowed_users`).
+ - `Init` constructs `*oidcstub.Stub` from `cfg.OIDC` (issuer/audience/username_claim shared with the verifier — see IV4), starts an `http.Server` bound to `cfg.OIDC.DevStub.Bind`, mints one token per `allowed_users` entry, and prints a banner via the injected logger.
+ - `Destroy` shuts the listener down with a short grace.
+- Registration in `cmd/lethe/main.go` — gated identical to existing `cfg.Auth.OIDC.Enabled`:
+ ```go
+ if cfg.Auth.OIDC.Enabled && cfg.Auth.OIDC.DevStub.Enabled {
+ devStub := &authpkg.OIDCDevStub{}
+ registered = append(registered, devStub)
+ mgr.AddComponent(ctx, steward.MustServiceAsset(devStub))
+ }
+ if cfg.Auth.OIDC.Enabled {
+ oidcSvc := &authpkg.OIDCVerifier{}
+ registered = append(registered, oidcSvc)
+ mgr.AddComponent(ctx, steward.MustServiceAsset(oidcSvc))
+ }
+ ```
+ Order matters: `OIDCDevStub` registers before `OIDCVerifier` so its `Init` (which binds the listener) runs first and the verifier's discovery call resolves (IV2).
+- `/dev/token` handler — same `*Stub.Handler()` mux, mounted alongside discovery + JWKS. Default TTL from config; `?exp=` overrides per-request. No auth on the endpoint — the listener is on loopback by operator's choice.
+
+**Why this shape over the alternatives:**
+
+- Same-process, separate listener resolves the steward Init-ordering problem without making `OIDCVerifier` lazy.
+- Shared `OIDCConfig` (read by both stub and verifier) makes "issuer/audience disagree" impossible by construction — the only string the operator can typo is `auth.oidc.dev_stub.bind` vs `auth.oidc.issuer`, which fails loudly because discovery doesn't resolve.
+- T3 (banner + endpoint) is additive over T1 — banner covers the 90% copy-paste flow; endpoint covers edge cases (custom `sub`, short expiry, scripted tests). ~30 LOC over T1-only.
+
+TDD: yes (library piece — `oidcstub` package and the existing middleware test that covers it end-to-end). The daemon-integration glue gets one focused unit test (`OIDCDevStub.Init` brings up listener; `/dev/token` issues a token that `OIDCVerifier.Verify` accepts), no exhaustive coverage of banner content.
+
+### Invariants
+
+- IV1 — `internal/testutil/oidcstub/` imports only stdlib + `go.bigb.es/auxilia/culpa`. No imports from `internal/server/auth/` or anywhere else in lethe internals.
+- IV2 — `OIDCDevStub` registers in steward before `OIDCVerifier`. Its listener accepts traffic by the time the verifier's `Init` calls `oidc.NewProvider`.
+- IV3 — When `auth.oidc.dev_stub.enabled = false` (default), no listener opens, no goroutines start, no banner prints, no `OIDCDevStub` asset is registered.
+- IV4 — Tokens minted by the integrated stub satisfy `iss == cfg.Auth.OIDC.Issuer`, `aud == cfg.Auth.OIDC.Audience`, and carry the configured `username_claim`. The dev-stub reads from the same `OIDCConfig` rather than holding parallel copies.
+- IV5 — The existing `internal/server/auth/middleware_test.go` cases pass unchanged in semantics after the helper move; only the import path and constructor name shift.
+
+### Principles
+
+- PC1 — The library API is `context.Context` + `error` only — no `*testing.T` coupling. Test-ergonomic wrappers (`MustSign(t, claims)`-style) live in a separate `oidcstubtest` sub-package if needed.
+- PC2 — Fail fast on config-issuer / dev-stub-bind disagreement. If discovery is unreachable the verifier already hard-fails at boot — that surfaces typos loudly, no extra guard needed.
+
+### Assumptions
+
+- AS1 — No production deployment sets `auth.oidc.dev_stub.enabled = true`. Gate is config-only by explicit operator choice; no build-tag or env-var defenses.
+- AS2 — `coreos/go-oidc/v3` accepts `http://` issuers (the in-test helper already relies on this; the daemon-integrated stub continues that).
+- AS3 — Signing key is regenerated on every daemon start. Tokens issued in a previous run stop verifying after restart. Acceptable for "quick localhost testing" — operator copies a fresh banner token.
+
+### Unknowns
+
+- UK1 — Should `/dev/token` accept claim overrides beyond `sub` and `exp` (e.g., `?email=…`, `?groups=…`) for testing edge cases like missing-username-claim? Defer to plan; default to `sub` + `exp` only and extend if the planner finds real need.
+
+## Plan
+
+Approach: lift the in-test helper into `internal/testutil/oidcstub/` with a `*testing.T`-free API (PH1), add the config fields (PH2), then wire a steward asset that owns a loopback listener serving discovery + JWKS + a `/dev/token` mint endpoint, ordered before the verifier so its discovery call resolves (PH3).
+
+### PH1 — Lift `oidcstub` to a public testutil package
+
+- **1.1** `internal/testutil/oidcstub/oidcstub.go` (create) — package `oidcstub`.
+ - `type Options struct { Issuer, Audience, UsernameClaim string; DefaultTTL time.Duration }` — `Issuer` mandatory (caller-supplied URL); `DefaultTTL` falls back to 1h when zero. Signing key generated fresh per `New` (AS3); kid hardcoded to `"oidcstub-key-1"` (single-stub-per-process — YAGNI on configurability).
+ - `type Stub struct { ... }` — holds key, kid, issuer, audience, username_claim, default_ttl. No listener.
+ - `func New(opts Options) (*Stub, error)` — validates `opts.Issuer != ""`, generates key if absent, returns ready-to-mount Stub.
+ - `func (s *Stub) Handler() http.Handler` — `http.ServeMux` with `/.well-known/openid-configuration`, `/jwks`, and `/dev/token`. Mounts on caller's listener.
+ - `func (s *Stub) SignToken(claims map[string]any) (string, error)` — RS256; merges defaults `{iss, iat, exp=iat+1h}` under caller claims (caller wins).
+ - `func (s *Stub) Mint(sub string, ttl time.Duration, extra map[string]any) (string, time.Time, error)` — convenience: sets `iss`, `aud`, `iat`, `exp=iat+ttl`, the configured `username_claim` (if non-empty) to `sub`, plus `sub=sub`; merges `extra` under those defaults so caller can override. Returns token + expires_at.
+ - `/dev/token` handler: parses `?sub=` (required, 400 if absent), `?exp=` (optional `time.ParseDuration`, defaults to `s.defaultTTL`), calls `Mint`, returns `{"token": "...", "expires_at": <unix>}` JSON.
+ - Errors wrap with `culpa.WithCode(_, "OIDCSTUB_*")` codes.
+ - Respects: IV1, PC1.
+- **1.2** `internal/testutil/oidcstub/oidcstub_test.go` (create) — package `oidcstub_test`.
+ - TDD-first: write before lifting code. Tests:
+ - `TestNew_RequiresIssuer` — `New(Options{})` returns error.
+ - `TestSignToken_RoundtripsThroughDiscovery` — mount `Handler()` on `httptest.NewServer`, set `opts.Issuer = srv.URL`, sign a token, fetch discovery + JWKS via `coreos/go-oidc`, verify token, assert `aud` and `sub` claims survive.
+ - `TestMint_SetsUsernameClaim` — `Options{UsernameClaim:"preferred_username"}`, `Mint("alice", time.Hour, nil)` produces a token whose claims include `preferred_username:"alice"`.
+ - `TestDevToken_HTTP_ReturnsBearer` — `GET /dev/token?sub=alice&exp=15m` returns 200 + parseable JSON; the token verifies under the same Stub.
+ - `TestDevToken_HTTP_400OnMissingSub` — `GET /dev/token` returns 400.
+- **1.3** `internal/server/auth/oidctestserver_test.go` (delete) — superseded by `oidcstub`.
+- **1.4** `internal/server/auth/middleware_test.go:171-409` (modify) — replace every `newOIDCTestServer(t)` / `o.signToken(t, claims)` / `o.newVerifier(t, "lethe")` site with the new package equivalents:
+ - Add a small file-local helper `newStub(t *testing.T) *oidcstub.Stub` that constructs the stub, wraps `httptest.NewServer(stub.Handler())`, sets `stub.Issuer = srv.URL` (or constructs after the test server is up via a `Stub.SetIssuer(string)` setter — see snippet below), and registers `t.Cleanup(srv.Close)`.
+ - Replace `o.signToken` with `oidcstub.MustSign(t, stub, claims)` — defined in this same test file as a one-liner wrapper, or call `stub.SignToken` and `t.Fatal` on err inline.
+ - Replace `o.newVerifier(t, "lethe")` with a local `newVerifier(t *testing.T, stub *oidcstub.Stub, aud string) *auth.OIDCVerifier` that builds an `OIDCVerifier{Cfg: ...}` and calls `Init`.
+ - Respects: IV5.
+- Commit: `auth: lift oidc test stub into internal/testutil/oidcstub`
+
+**Snippet — listener-issuer ordering** (the one tricky bit; everything else is straight code):
+```go
+// httptest.NewServer needs a handler; the handler needs a Stub; the Stub
+// wants the issuer URL — which only exists after NewServer returns. Two
+// resolutions:
+//
+// (a) Construct the Stub with a placeholder issuer, swap via SetIssuer
+// after NewServer is up. Simple, but Stub now has mutable state.
+// (b) Use httptest.NewUnstartedServer, install Handler from a closure that
+// captures *Stub (still nil), call srv.Start(), then assign Stub with
+// srv.URL as Issuer. More wiring.
+//
+// Choose (a). One setter, called once during construction, then frozen.
+```
+
+### PH2 — Config: `auth.oidc.dev_stub` block
+
+- **2.1** `internal/config/config.go:68-74` (modify) — extend `OIDCConfig`:
+ ```go
+ type OIDCConfig struct {
+ Enabled bool `mapstructure:"enabled"`
+ Issuer string `mapstructure:"issuer" validate:"required_if=Enabled true,omitempty,url"`
+ Audience string `mapstructure:"audience" validate:"required_if=Enabled true"`
+ UsernameClaim string `mapstructure:"username_claim"`
+ DevStub OIDCDevStubConfig `mapstructure:"dev_stub"`
+ }
+
+ type OIDCDevStubConfig struct {
+ Enabled bool `mapstructure:"enabled"`
+ Bind string `mapstructure:"bind" validate:"required_if=Enabled true,omitempty,loopback_bind"`
+ TokenTTL time.Duration `mapstructure:"token_ttl" validate:"omitempty,gt=0"`
+ }
+ ```
+ - Reuse the existing `loopback_bind` validator (config.go:91 + config.go:194). Sibling-consistent with `ServerConfig.Bind`; not a new defense, just same hygiene as the main listener. Per AS1, *gating* is config-only — that's `dev_stub.enabled`, not the bind validator.
+- **2.2** `internal/config/config.go:157-165` (modify) `registerDefaults` — add `v.SetDefault("auth.oidc.dev_stub.token_ttl", 24*time.Hour)`.
+- **2.3** `internal/config/config_test.go:33-50` (modify) — one new positive YAML `validOIDCDevStubYAML` with `dev_stub.enabled: true` and a loopback bind; one new negative test `TestLoad_DevStubBindNonLoopback_Rejects` for `bind: "0.0.0.0:8181"`; extend `TestLoad_Defaults` with `cfg.Auth.OIDC.DevStub.TokenTTL == 24*time.Hour` when block omitted.
+- **2.4** `config.example.yaml` (modify) — add a commented-out `dev_stub:` block under `oidc:` with a one-line operator note ("local-dev only — never enable in production").
+- Commit: `config: add auth.oidc.dev_stub block (disabled by default)`
+- Respects: IV3 (default disabled), AS1 (config-only gate).
+
+### PH3 — Daemon-integrated dev stub
+
+- **3.1** `internal/server/auth/devstub.go` (create) — package `auth`.
+ - `type OIDCDevStub struct { Cfg config.AuthConfig `config:""`; Log *observability.Logger `inject:""`; stub *oidcstub.Stub; srv *http.Server }`.
+ - `func (d *OIDCDevStub) Init(ctx context.Context) error` — guard `if !Cfg.OIDC.Enabled || !Cfg.OIDC.DevStub.Enabled { return nil }` (defense-in-depth; main.go also gates registration). Builds `oidcstub.Stub` from `Cfg.OIDC` (issuer/audience/username_claim shared — IV4). Starts `http.Server{Addr: Cfg.OIDC.DevStub.Bind, Handler: stub.Handler()}` in a goroutine, with a 1-second readiness probe (`net.Dial` loop with deadline) so Init returns only once the listener accepts. Mints one token per `Cfg.AllowedUsers` entry with TTL `Cfg.OIDC.DevStub.TokenTTL` and logs a structured banner.
+ - `func (d *OIDCDevStub) Destroy(ctx context.Context) error` — `srv.Shutdown(ctx)` with the caller's context budget.
+ - Respects: IV2, IV3, IV4.
+- **3.2** `internal/server/auth/devstub_test.go` (create) — TDD-first.
+ - `TestOIDCDevStub_InitStartsListener_TokenVerifies`:
+ - Pick a free loopback port via `net.Listen("tcp","127.0.0.1:0")` then close (port races are fine in test scale).
+ - Build `OIDCDevStub` with `Cfg` containing `OIDC.Enabled=true, Issuer="http://127.0.0.1:<port>", Audience="lethe", UsernameClaim="preferred_username", DevStub.Enabled=true, DevStub.Bind="127.0.0.1:<port>", DevStub.TokenTTL=time.Hour, AllowedUsers=["alice"]`.
+ - `Init`, then construct an `auth.OIDCVerifier` against the same issuer/audience and `Init` it (proves the listener is up and discovery resolves).
+ - `GET http://127.0.0.1:<port>/dev/token?sub=alice` returns a bearer that `verifier.Verify(ctx, token)` accepts and yields user `"alice"`.
+ - `t.Cleanup` calls `Destroy`.
+ - `TestOIDCDevStub_DisabledIsNoop` — `DevStub.Enabled=false`, `Init` returns nil, no listener bound.
+- **3.3** `cmd/lethe/main.go:108-138` (modify) — register `OIDCDevStub` *before* `OIDCVerifier`:
+ ```go
+ if cfg.Auth.OIDC.Enabled && cfg.Auth.OIDC.DevStub.Enabled {
+ devStubSvc := &authpkg.OIDCDevStub{}
+ registered = append(registered, devStubSvc)
+ mgr.AddComponent(ctx, steward.MustServiceAsset(devStubSvc))
+ }
+ if cfg.Auth.OIDC.Enabled {
+ oidcSvc := &authpkg.OIDCVerifier{}
+ registered = append(registered, oidcSvc)
+ mgr.AddComponent(ctx, steward.MustServiceAsset(oidcSvc))
+ }
+ ```
+ - Respects: IV2, IV3.
+- Commit: `auth: integrate oidcstub as opt-in dev OP under auth.oidc.dev_stub`
+
+### Backwards compatibility
+
+Additive only. PH1 deletes a `_test.go`-only helper file consumed by exactly one sibling test (which moves with it) — no public API. PH2 adds new config fields, all defaulted to disabled. PH3 conditionally registers a new asset under `cfg.Auth.OIDC.DevStub.Enabled`. Existing prod startup is byte-identical.
+
+### Test strategy
+
+TDD: yes for PH1 and PH3 (red → green per PH1.2 / PH3.2 bullets). PH2 is config plumbing covered by the existing `config_test.go` style — extend, don't TDD.
+
+PH1 indirect coverage: the entire existing `middleware_test.go` (15 OIDC test cases) re-runs against the lifted package via PH1.4 import flip — any regression in `Stub.SignToken` / `Stub.Handler` shape surfaces in CI without writing new assertions.
+
+PH3 banner output (slog line content, exact format) intentionally not asserted — too brittle, low-value.
+
+### Order & dependencies
+
+PH1 and PH2 are independent (disjoint files, no runtime coupling) — same wave. PH3 depends on both: imports `oidcstub` (PH1) and reads `config.OIDCDevStubConfig` (PH2).
+
+### Risks / rollback
+
+- RK1 — Steward Init order: registering `OIDCDevStub` after `OIDCVerifier` makes verifier discovery hit a closed port and `mgr.Init` fails. PH3.3 specifies the order; PH3.2's first test exercises it; reviewer can spot a swap in the diff.
+- RK2 — Listener-readiness race: `http.Server.ListenAndServe` returns only on shutdown; Init must confirm the socket accepts before returning, else a fast-following `OIDCVerifier.Init` discovery call races. PH3.1 readiness probe (1-second `net.Dial` loop) covers this.
+- RK3 — `Stub.Issuer` mutability (PH1 snippet option (a)): a second `SetIssuer` call mid-flight would invalidate already-minted tokens. Mitigation: make the field private with a one-shot setter that errors on second call, or document "construction-time only".
+
+Rollback per phase: each commit is independently revertable. PH3 alone reverts cleanly; PH2 alone reverts (no consumer); PH1 alone reverts but middleware_test.go won't compile until the helper file is restored — keep `git revert` of PH1 + restore deleted file in same operation.
+
+### Interfaces
+
+- IF1 — `oidcstub.New(opts oidcstub.Options) (*oidcstub.Stub, error)` — constructor; opts.Issuer mandatory.
+- IF2 — `(*oidcstub.Stub).Mint(sub string, ttl time.Duration, extra map[string]any) (string, time.Time, error)` — mint primitive; called by banner code and `/dev/token` handler.
+- IF3 — `(*oidcstub.Stub).Handler() http.Handler` — discovery + JWKS + `/dev/token` mux.
+- IF4 — `config.OIDCDevStubConfig{Enabled bool; Bind string; TokenTTL time.Duration}` — config shape consumed by PH3.
+
+### Interface graph
+
+- PH1 -> IF1, IF2, IF3 @ internal/testutil/oidcstub/, internal/server/auth/middleware_test.go, internal/server/auth/oidctestserver_test.go
+- PH2 -> IF4 @ internal/config/config.go, internal/config/config_test.go, config.example.yaml
+- PH3 IF1, IF2, IF3, IF4 -> @ internal/server/auth/devstub.go, internal/server/auth/devstub_test.go, cmd/lethe/main.go
+