From ff633decd7949efd3dea9e056b06a3a659406494 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 26 Apr 2026 15:01:28 +0300 Subject: [PATCH] auth: integrate oidcstub as opt-in dev OP under auth.oidc.dev_stub --- cmd/lethe/main.go | 5 + internal/server/auth/devstub.go | 153 +++++++++++++++++++++++ internal/server/auth/devstub_test.go | 174 +++++++++++++++++++++++++++ 3 files changed, 332 insertions(+) create mode 100644 internal/server/auth/devstub.go create mode 100644 internal/server/auth/devstub_test.go diff --git a/cmd/lethe/main.go b/cmd/lethe/main.go index 21ea41b54f08c254cc36dbf3b1f8f0b9aa17f5ed..c9f198dc12faf484d3b39f6452f6ffbbea73c41b 100644 --- a/cmd/lethe/main.go +++ b/cmd/lethe/main.go @@ -131,6 +131,11 @@ func run() int { steward.MustServiceAsset(serverSvc, steward.Root()), ) + 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) diff --git a/internal/server/auth/devstub.go b/internal/server/auth/devstub.go new file mode 100644 index 0000000000000000000000000000000000000000..15fe7ea3b9c59796226bb87677b3d88ccf433af0 --- /dev/null +++ b/internal/server/auth/devstub.go @@ -0,0 +1,153 @@ +// Package auth — OIDCDevStub provides a steward-managed in-process OIDC +// provider for local development. It is registered before OIDCVerifier so its +// listener accepts traffic by the time the verifier's Init calls +// oidc.NewProvider (IV2). When DevStub.Enabled is false the component is a +// no-op: no listener, no goroutines, no banner (IV3). +package auth + +import ( + "context" + "log/slog" + "net" + "net/http" + "time" + + "go.bigb.es/auxilia/culpa" + + "sourcecraft.dev/bigbes/lethe/internal/config" + "sourcecraft.dev/bigbes/lethe/internal/platform/observability" + "sourcecraft.dev/bigbes/lethe/internal/testutil/oidcstub" +) + +// OIDCDevStub is a steward-managed in-process OIDC identity provider for +// local development. Construct as a zero value with Cfg and Log set, then +// call Init. When OIDC or DevStub are not enabled, Init is a no-op. +// +// Fields are exported for steward injection. +type OIDCDevStub struct { + Cfg config.AuthConfig `config:""` + Log *observability.Logger `inject:""` + + stub *oidcstub.Stub + srv *http.Server +} + +// Init starts the stub OIDC provider. It is a no-op when either +// cfg.Auth.OIDC.Enabled or cfg.Auth.OIDC.DevStub.Enabled is false. +// +// When enabled it: +// 1. Builds an oidcstub.Stub from the shared OIDC config (IV4). +// 2. Starts an http.Server on Cfg.OIDC.DevStub.Bind in a goroutine. +// 3. Polls the bind address (1 s deadline, 100 ms dial timeout) so Init +// returns only once the listener accepts connections (IV2). +// 4. Mints one token per AllowedUsers entry and logs a banner. +func (d *OIDCDevStub) Init(ctx context.Context) error { + // Defense-in-depth guard; main.go also gates registration (IV3). + if !d.Cfg.OIDC.Enabled || !d.Cfg.OIDC.DevStub.Enabled { + return nil + } + + ttl := d.Cfg.OIDC.DevStub.TokenTTL + if ttl == 0 { + ttl = 24 * time.Hour + } + + // Build the stub using the same issuer/audience/username_claim that the + // production OIDCVerifier will use (IV4). + stub, err := oidcstub.New(oidcstub.Options{ + Issuer: d.Cfg.OIDC.Issuer, + Audience: d.Cfg.OIDC.Audience, + UsernameClaim: d.Cfg.OIDC.UsernameClaim, + DefaultTTL: ttl, + }) + if err != nil { + return culpa.WithCode(culpa.Wrap(err, "oidcstub init"), "OIDC_DEVSTUB_INIT") + } + d.stub = stub + + srv := &http.Server{ + Addr: d.Cfg.OIDC.DevStub.Bind, + Handler: stub.Handler(), + } + d.srv = srv + + // Channel to capture fatal startup errors from the server goroutine. + // Buffered so the goroutine never blocks if Init has already returned. + errCh := make(chan error, 1) + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + errCh <- err + } + }() + + // Readiness probe: poll until the listener accepts connections or the + // deadline expires. + bind := d.Cfg.OIDC.DevStub.Bind + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + // Check if the goroutine reported a fatal startup error. + select { + case startErr := <-errCh: + return culpa.WithCode( + culpa.Wrap(startErr, "oidcstub server failed to start"), + "OIDC_DEVSTUB_INIT", + ) + default: + } + + conn, dialErr := net.DialTimeout("tcp", bind, 100*time.Millisecond) + if dialErr == nil { + conn.Close() + goto ready + } + time.Sleep(20 * time.Millisecond) + } + + // One final check for a startup error before returning timeout. + select { + case startErr := <-errCh: + return culpa.WithCode( + culpa.Wrap(startErr, "oidcstub server failed to start"), + "OIDC_DEVSTUB_INIT", + ) + default: + } + + return culpa.WithCode( + culpa.New("oidcstub listener readiness timeout on "+bind), + "OIDC_DEVSTUB_INIT", + ) + +ready: + // Mint one dev token per allowed user and emit the banner. + for _, user := range d.Cfg.AllowedUsers { + tok, expiresAt, mintErr := stub.Mint(user, ttl, nil) + if mintErr != nil { + return culpa.WithCode(culpa.Wrap(mintErr, "mint dev token for "+user), "OIDC_DEVSTUB_INIT") + } + d.Log.L.Info("oidc dev stub token", + slog.String("user", user), + slog.String("issuer", d.Cfg.OIDC.Issuer), + slog.String("audience", d.Cfg.OIDC.Audience), + slog.Time("expires_at", expiresAt), + slog.String("token", tok), + ) + } + + d.Log.L.Info("oidc dev stub started", + slog.String("bind", bind), + slog.String("issuer", d.Cfg.OIDC.Issuer), + slog.Int("allowed_users", len(d.Cfg.AllowedUsers)), + ) + + return nil +} + +// Destroy shuts the stub HTTP server down using the caller's context budget. +// It is safe to call when the stub was never started (no-op path). +func (d *OIDCDevStub) Destroy(ctx context.Context) error { + if d.srv == nil { + return nil + } + return d.srv.Shutdown(ctx) +} diff --git a/internal/server/auth/devstub_test.go b/internal/server/auth/devstub_test.go new file mode 100644 index 0000000000000000000000000000000000000000..b1749eb9179ef73fd3246ed6d3308c4658e17d64 --- /dev/null +++ b/internal/server/auth/devstub_test.go @@ -0,0 +1,174 @@ +package auth_test + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "testing" + "time" + + "sourcecraft.dev/bigbes/lethe/internal/config" + "sourcecraft.dev/bigbes/lethe/internal/platform/observability" + "sourcecraft.dev/bigbes/lethe/internal/server/auth" +) + +// freeLoopbackPort picks an available TCP port on 127.0.0.1 by briefly +// binding and then releasing it. Port-race is acceptable at test scale. +func freeLoopbackPort(t *testing.T) int { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("freeLoopbackPort: Listen: %v", err) + } + port := ln.Addr().(*net.TCPAddr).Port + ln.Close() + return port +} + +// newTestLogger builds an initialised observability.Logger for use in tests. +func newTestLogger(t *testing.T) *observability.Logger { + t.Helper() + logger := &observability.Logger{Cfg: config.LoggingConfig{Level: "info", Format: "json"}} + if err := logger.Init(context.Background()); err != nil { + t.Fatalf("logger.Init: %v", err) + } + return logger +} + +// TestOIDCDevStub_InitStartsListener_TokenVerifies verifies that: +// 1. OIDCDevStub.Init starts an HTTP listener on the configured bind address. +// 2. The listener satisfies OIDC discovery so OIDCVerifier.Init succeeds. +// 3. GET /dev/token?sub=alice returns a JWT that OIDCVerifier.Verify accepts +// and resolves to username "alice". +func TestOIDCDevStub_InitStartsListener_TokenVerifies(t *testing.T) { + port := freeLoopbackPort(t) + bind := fmt.Sprintf("127.0.0.1:%d", port) + issuer := "http://" + bind + + cfg := config.AuthConfig{ + AllowedUsers: []string{"alice"}, + OIDC: config.OIDCConfig{ + Enabled: true, + Issuer: issuer, + Audience: "lethe", + UsernameClaim: "preferred_username", + DevStub: config.OIDCDevStubConfig{ + Enabled: true, + Bind: bind, + TokenTTL: time.Hour, + }, + }, + } + + stub := &auth.OIDCDevStub{ + Cfg: cfg, + Log: newTestLogger(t), + } + + ctx := context.Background() + + if err := stub.Init(ctx); err != nil { + t.Fatalf("OIDCDevStub.Init: %v", err) + } + t.Cleanup(func() { + shutCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := stub.Destroy(shutCtx); err != nil { + t.Logf("OIDCDevStub.Destroy: %v", err) + } + }) + + // Prove the listener answers OIDC discovery by constructing a real verifier. + verifier := &auth.OIDCVerifier{ + Cfg: config.OIDCConfig{ + Enabled: true, + Issuer: issuer, + Audience: "lethe", + UsernameClaim: "preferred_username", + }, + } + if err := verifier.Init(ctx); err != nil { + t.Fatalf("OIDCVerifier.Init: %v", err) + } + + // Fetch a token from the /dev/token endpoint. + resp, err := http.Get(fmt.Sprintf("http://%s/dev/token?sub=alice", bind)) + if err != nil { + t.Fatalf("GET /dev/token: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("/dev/token status = %d; body = %s", resp.StatusCode, body) + } + + var tokenResp struct { + Token string `json:"token"` + } + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + t.Fatalf("decode token response: %v", err) + } + if tokenResp.Token == "" { + t.Fatal("token is empty") + } + + // Verify the token resolves to alice. + user, err := verifier.Verify(ctx, tokenResp.Token) + if err != nil { + t.Fatalf("verifier.Verify: %v", err) + } + if user != "alice" { + t.Errorf("Verify returned user = %q; want alice", user) + } +} + +// TestOIDCDevStub_DisabledIsNoop verifies that when DevStub.Enabled is false +// Init returns nil without starting any listener. +func TestOIDCDevStub_DisabledIsNoop(t *testing.T) { + port := freeLoopbackPort(t) + bind := fmt.Sprintf("127.0.0.1:%d", port) + issuer := "http://" + bind + + cfg := config.AuthConfig{ + AllowedUsers: []string{"alice"}, + OIDC: config.OIDCConfig{ + Enabled: true, + Issuer: issuer, + Audience: "lethe", + UsernameClaim: "preferred_username", + DevStub: config.OIDCDevStubConfig{ + Enabled: false, + Bind: bind, + }, + }, + } + + stub := &auth.OIDCDevStub{ + Cfg: cfg, + Log: newTestLogger(t), + } + + ctx := context.Background() + if err := stub.Init(ctx); err != nil { + t.Fatalf("OIDCDevStub.Init (disabled): %v", err) + } + + // Give the goroutine scheduler a moment, then assert nothing is listening. + time.Sleep(50 * time.Millisecond) + + conn, err := net.DialTimeout("tcp", bind, 100*time.Millisecond) + if err == nil { + conn.Close() + t.Errorf("expected no listener on %s when DevStub.Enabled=false, but dialled successfully", bind) + } + + // Destroy on a no-op stub must also be safe. + shutCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := stub.Destroy(shutCtx); err != nil { + t.Errorf("OIDCDevStub.Destroy (disabled): %v", err) + } +}