// 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) }