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