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 unblocksPromote 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.
In:
internal/testutil/oidcstub/ — exported, leaf, *testing.T-free.authpkg.DevStub (or authpkg.OIDCDevStub) registered in cmd/lethe/main.go only when cfg.Auth.OIDC.DevStub.Enabled.auth.oidc.dev_stub (additive — enabled, bind, optional token_ttl).allowed_users entry, printed to stdout / a logger line.GET /dev/token?sub=<user>&exp=<duration> returning { "token": "...", "expires_at": <unix> }.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).Library piece (internal/testutil/oidcstub/):
_test.go. New file internal/testutil/oidcstub/oidcstub.go.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.*testing.T. Errors return; cleanup is the caller's problem.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):
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
}
authpkg.OIDCDevStub:
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.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))
}
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:
OIDCVerifier lazy.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.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.
internal/testutil/oidcstub/ imports only stdlib + go.bigb.es/auxilia/culpa. No imports from internal/server/auth/ or anywhere else in lethe internals.OIDCDevStub registers in steward before OIDCVerifier. Its listener accepts traffic by the time the verifier's Init calls oidc.NewProvider.auth.oidc.dev_stub.enabled = false (default), no listener opens, no goroutines start, no banner prints, no OIDCDevStub asset is registered.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.internal/server/auth/middleware_test.go cases pass unchanged in semantics after the helper move; only the import path and constructor name shift.context.Context + error only — no *testing.T coupling. Test-ergonomic wrappers (MustSign(t, claims)-style) live in a separate oidcstubtest sub-package if needed.auth.oidc.dev_stub.enabled = true. Gate is config-only by explicit operator choice; no build-tag or env-var defenses.coreos/go-oidc/v3 accepts http:// issuers (the in-test helper already relies on this; the daemon-integrated stub continues that)./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.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).
oidcstub to a public testutil packageinternal/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.culpa.WithCode(_, "OIDCSTUB_*") codes.internal/testutil/oidcstub/oidcstub_test.go (create) — package oidcstub_test.
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.internal/server/auth/oidctestserver_test.go (delete) — superseded by oidcstub.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:
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).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.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.auth: lift oidc test stub into internal/testutil/oidcstubSnippet — 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.
auth.oidc.dev_stub blockinternal/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"`
}
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.internal/config/config.go:157-165 (modify) registerDefaults — add v.SetDefault("auth.oidc.dev_stub.token_ttl", 24*time.Hour).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.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").config: add auth.oidc.dev_stub block (disabled by default)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.internal/server/auth/devstub_test.go (create) — TDD-first.
TestOIDCDevStub_InitStartsListener_TokenVerifies:
net.Listen("tcp","127.0.0.1:0") then close (port races are fine in test scale).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.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))
}
auth: integrate oidcstub as opt-in dev OP under auth.oidc.dev_stubAdditive 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.
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.
PH1 and PH2 are independent (disjoint files, no runtime coupling) — same wave. PH3 depends on both: imports oidcstub (PH1) and reads config.OIDCDevStubConfig (PH2).
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.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.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.
oidcstub.New(opts oidcstub.Options) (*oidcstub.Stub, error) — constructor; opts.Issuer mandatory.(*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.(*oidcstub.Stub).Handler() http.Handler — discovery + JWKS + /dev/token mux.config.OIDCDevStubConfig{Enabled bool; Bind string; TokenTTL time.Duration} — config shape consumed by PH3.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:
internal/testutil/oidcstub/oidcstub.go imports stdlib + go.bigb.es/auxilia/culpa only.OIDCDevStub registers ahead of OIDCVerifier in cmd/lethe/main.go:134-143; TestOIDCDevStub_InitStartsListener_TokenVerifies and live smoke both confirm verifier discovery resolves.TestOIDCDevStub_DisabledIsNoop covers default-disabled no-listener path; cmd/lethe/main.go:134 registers the asset only when both flags are true.OIDCDevStub reads from Cfg.OIDC.{Issuer,Audience,UsernameClaim} and constructs oidcstub.New(...) with the same values; smoke proved verifier accepts stub-minted tokens.internal/server/auth/middleware_test.go retains all 22 OIDC + forward-auth cases passing post-refactor.auth.oidc.dev_stub.enabled: true in YAML.coreos/go-oidc/v3 accepted http://127.0.0.1:8191 in live smoke.rsa.GenerateKey(rand.Reader, 2048) is called inside New at oidcstub.go:66; tokens from a previous run stop verifying after restart by design./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.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.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.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: ...}.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).Result: passed
Positive:
go test -count=1 ./internal/testutil/oidcstub/... → 5/5 passgo test -count=1 ./internal/server/auth/... → 24/24 pass (covers IV5 — refactored middleware_test cases unchanged in semantics)go test -count=1 ./internal/config/... → 18/18 pass (incl. new positive + non-loopback-rejects fixtures)go build ./cmd/lethe → cleanGET /api/v1/sessions with bearer from /dev/token → 200Negative:
GET /api/v1/sessions no bearer → 401 UNAUTHORIZED problem+jsonGET /api/v1/sessions Bearer not-a-jwt → 401GET /dev/token no ?sub= → 400 {"error":"sub is required"}dev_stub.bind: "0.0.0.0:8191" rejected at load: 'Bind' failed on the 'loopback_bind' tag (CONFIG_VALIDATE)Invariants / assumptions:
oidcstub imports stdlib + go.bigb.es/auxilia/culpa onlyTestOIDCDevStub_InitStartsListener_TokenVerifies exercises listener-up-before-discovery; smoke confirms in real processTestOIDCDevStub_DisabledIsNoop covers default-disabled no-listener pathOIDCVerifier driven from the same AuthConfig — iss/aud/username_claim agree by constructioncoreos/go-oidc/v3 accepted http://127.0.0.1:8191 in smokersa.GenerateKey at oidcstub.go:66 inside New — fresh per constructionInterfaces:
oidcstub.New(oidcstub.Options{...}) call sites, all match (Options) (*Stub, error)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 returnstub.Handler() passed to http.Server.Handler (devstub.go:70) and httptest.NewServer (test sites)config.OIDCDevStubConfig{Enabled, Bind, TokenTTL} constructed at devstub_test.go:58/144; struct definition at config.go:80Smoke: ./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.