~bigbes/lethe

5928a62ab320b3c552c39d2a1fbf1692e88bf5d5 — Eugene Blikh a month ago ac2cd65
docs(lethe-oidc-stub): design + plan
1 files changed, 257 insertions(+), 0 deletions(-)

A docs/tasks/lethe-oidc-stub.md
A docs/tasks/lethe-oidc-stub.md => docs/tasks/lethe-oidc-stub.md +257 -0
@@ 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