M cmd/lethe/main.go => cmd/lethe/main.go +5 -0
@@ 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)
A internal/server/auth/devstub.go => internal/server/auth/devstub.go +153 -0
@@ 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)
+}
A internal/server/auth/devstub_test.go => internal/server/auth/devstub_test.go +174 -0
@@ 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)
+ }
+}