# 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 ## Conclusion ### Deviations from plan - PH1 — added `Stub.Issuer() string` getter beyond the planned `SetIssuer`. **Why:** `middleware_test.go` builds `config.OIDCConfig{Issuer: ...}` after `httptest.NewServer` returns, and needs to read the value back from the stub. Read-only; no mutable-state concern. - PH1 — local helper renamed from `newVerifier` (plan) to `newOIDCVerifier`. **Why:** symbol clarity in a test file that already has `newAuthenticator`, `newStub`; one verifier/authenticator name pair reads better. Test-file only. - Verify-driven fix-up — `OIDCVerifier.Cfg` retyped from `config.OIDCConfig` to `config.AuthConfig`; `Init` rewired to read `Cfg.OIDC.`. **Why:** smoke test surfaced a pre-existing latent steward-injection bug (origin `80b1c09`) that prevented lethe from booting with `oidc.enabled=true`. Fix is sibling-consistent with `Authenticator` (middleware.go:84) and `OIDCDevStub` (devstub.go:28). Two test call sites updated to wrap in `config.AuthConfig{OIDC: ...}`. - Verify-driven fix-up — `OIDCDevStub` registered with `steward.Root()` modifier in `cmd/lethe/main.go:137`. **Why:** smoke test surfaced steward ERR/WRN `empty dependents asset without root option asset=auth.OIDCDevStub` because the dev stub has no in-process dependents (it's a side-effect listener). Adding `steward.Root()` silences the warning AND makes shutdown call `OIDCDevStub.Destroy` (verified in shutdown log). ## Verify **Result:** passed Positive: - CK1 — `go test -count=1 ./internal/testutil/oidcstub/...` → 5/5 pass - CK2 — `go test -count=1 ./internal/server/auth/...` → 24/24 pass (covers IV5 — refactored middleware_test cases unchanged in semantics) - CK3 — `go test -count=1 ./internal/config/...` → 18/18 pass (incl. new positive + non-loopback-rejects fixtures) - CK4 — `go build ./cmd/lethe` → clean - CK5 — Smoke: lethe boots, `GET /api/v1/sessions` with bearer from `/dev/token` → 200 Negative: - CK6 — `GET /api/v1/sessions` no bearer → 401 UNAUTHORIZED problem+json - CK7 — `GET /api/v1/sessions` `Bearer not-a-jwt` → 401 - CK8 — `GET /dev/token` no `?sub=` → 400 `{"error":"sub is required"}` - CK9 — config with `dev_stub.bind: "0.0.0.0:8191"` rejected at load: `'Bind' failed on the 'loopback_bind' tag` (CONFIG_VALIDATE) Invariants / assumptions: - CK10 (IV1) — `oidcstub` imports stdlib + `go.bigb.es/auxilia/culpa` only - CK11 (IV2) — `TestOIDCDevStub_InitStartsListener_TokenVerifies` exercises listener-up-before-discovery; smoke confirms in real process - CK12 (IV3) — `TestOIDCDevStub_DisabledIsNoop` covers default-disabled no-listener path - CK13 (IV4) — smoke: stub-minted token accepted by `OIDCVerifier` driven from the same `AuthConfig` — iss/aud/username_claim agree by construction - CK14 (IV5) — covered by CK2 - CK15 (AS1) — unverifiable at this layer (operator-only choice) - CK16 (AS2) — `coreos/go-oidc/v3` accepted `http://127.0.0.1:8191` in smoke - CK17 (AS3) — `rsa.GenerateKey` at `oidcstub.go:66` inside `New` — fresh per construction Interfaces: - CK18 (IF1) — 7 `oidcstub.New(oidcstub.Options{...})` call sites, all match `(Options) (*Stub, error)` - CK19 (IF2) — `stub.Mint(sub string, ttl time.Duration, extra map[string]any)` called at `devstub.go:124` and `oidcstub_test.go:94` matching declared 3-tuple return - CK20 (IF3) — `stub.Handler()` passed to `http.Server.Handler` (`devstub.go:70`) and `httptest.NewServer` (test sites) - CK21 (IF4) — `config.OIDCDevStubConfig{Enabled, Bind, TokenTTL}` constructed at `devstub_test.go:58/144`; struct definition at `config.go:80` Smoke: `./tmp/lethe-verify -config tmp/dev-stub-verify.yaml` → discovery 200, mint OK, sessions 200 with bearer / 401 without; clean shutdown including `destroying component=auth.OIDCDevStub`.