~bigbes/lethe

ref: 72508a536dd54662af89cce0002f6f3dee1ed0f8 lethe/docs/tasks/lethe-oidc-stub.md -rw-r--r-- 25.9 KiB
72508a53 — Eugene Blikh docs: refresh search and opencode plan 24 days ago

#lethe-oidc-stub

Status: Reviewed 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:
    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:
    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.Handlerhttp.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_RequiresIssuerNew(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_SetsUsernameClaimOptions{UsernameClaim:"preferred_username"}, Mint("alice", time.Hour, nil) produces a token whose claims include preferred_username:"alice".
      • TestDevToken_HTTP_ReturnsBearerGET /dev/token?sub=alice&exp=15m returns 200 + parseable JSON; the token verifies under the same Stub.
      • TestDevToken_HTTP_400OnMissingSubGET /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):

// 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:
    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) errorsrv.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_DisabledIsNoopDevStub.Enabled=false, Init returns nil, no listener bound.
  • 3.3 cmd/lethe/main.go:108-138 (modify) — register OIDCDevStub before OIDCVerifier:
    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

Outcome: oidcstub lifted to internal/testutil/oidcstub/ and wired into the daemon as an opt-in dev OP under auth.oidc.dev_stub; verify-driven fix-up corrected a pre-existing latent steward injection bug in OIDCVerifier. HEAD 05f80f3.

Invariants:

  • IV1 — internal/testutil/oidcstub/oidcstub.go imports stdlib + go.bigb.es/auxilia/culpa only.
  • IV2 — OIDCDevStub registers ahead of OIDCVerifier in cmd/lethe/main.go:134-143; TestOIDCDevStub_InitStartsListener_TokenVerifies and live smoke both confirm verifier discovery resolves.
  • IV3 — TestOIDCDevStub_DisabledIsNoop covers default-disabled no-listener path; cmd/lethe/main.go:134 registers the asset only when both flags are true.
  • IV4 — OIDCDevStub reads from Cfg.OIDC.{Issuer,Audience,UsernameClaim} and constructs oidcstub.New(...) with the same values; smoke proved verifier accepts stub-minted tokens.
  • IV5 — internal/server/auth/middleware_test.go retains all 22 OIDC + forward-auth cases passing post-refactor.

#Assumptions check

  • AS1 — held. Gate is config-only; operator must explicitly set auth.oidc.dev_stub.enabled: true in YAML.
  • AS2 — held. coreos/go-oidc/v3 accepted http://127.0.0.1:8191 in live smoke.
  • AS3 — held. rsa.GenerateKey(rand.Reader, 2048) is called inside New at oidcstub.go:66; tokens from a previous run stop verifying after restart by design.

#Unknowns outcome

  • UK1 — resolved: /dev/token accepts only ?sub= + ?exp=. No additional claim overrides shipped; the banner + endpoint cover every observed need in execute and verify, and the API can extend additively if a real edge case surfaces.

#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.<field>. 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.