~bigbes/lethe

ff633decd7949efd3dea9e056b06a3a659406494 — Eugene Blikh a month ago 717e25b
auth: integrate oidcstub as opt-in dev OP under auth.oidc.dev_stub
3 files changed, 332 insertions(+), 0 deletions(-)

M cmd/lethe/main.go
A internal/server/auth/devstub.go
A internal/server/auth/devstub_test.go
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)
	}
}