From 5928a62ab320b3c552c39d2a1fbf1692e88bf5d5 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 26 Apr 2026 14:56:30 +0300 Subject: [PATCH] docs(lethe-oidc-stub): design + plan --- docs/tasks/lethe-oidc-stub.md | 257 ++++++++++++++++++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 docs/tasks/lethe-oidc-stub.md diff --git a/docs/tasks/lethe-oidc-stub.md b/docs/tasks/lethe-oidc-stub.md new file mode 100644 index 0000000000000000000000000000000000000000..4a467d8c788092b8b7dc6a37b3e2b3aecf36e879 --- /dev/null +++ b/docs/tasks/lethe-oidc-stub.md @@ -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=&exp=` returning `{ "token": "...", "expires_at": }`. +- 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": }` 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:", Audience="lethe", UsernameClaim="preferred_username", DevStub.Enabled=true, DevStub.Bind="127.0.0.1:", 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:/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 +