~bigbes/lethe

80b1c095e36243a2e9035fc2367f181954a8af5b — Eugene Blikh a month ago 3c45b48
feat(auth): forward-auth + OIDC bearer middleware with shared allowlist
6 files changed, 976 insertions(+), 41 deletions(-)

D internal/deps/deps.go
D internal/server/auth/authenticator.go
A internal/server/auth/middleware.go
A internal/server/auth/middleware_test.go
A internal/server/auth/oidc.go
A internal/server/auth/oidctestserver_test.go
D internal/deps/deps.go => internal/deps/deps.go +0 -19
@@ 1,19 0,0 @@
// Package deps records the locked set of direct dependencies for the lethe
// server during early scaffolding. Real packages adopt these as they come
// online (auth — go-oidc).
//
// Phase 2 promoted viper, validator/v10, and culpa to real imports under
// internal/config. Phase 3 promoted sqlx, modernc.org/sqlite, and
// golang-migrate/v4 (plus its sqlite driver and iofs source) to real imports
// under internal/platform/database, and culpa is now used there too. Phase 4
// promoted prometheus/client_golang and auxilia/scribe (under
// internal/platform/observability) and auxilia/steward (canary test under
// internal/platform/health) to real imports. Phase 5 promoted chi/v5 to a
// real import under internal/server. Once every dep below has at least one
// real importer, this file is expected to disappear in the same commit that
// completes the migration.
package deps

import (
	_ "github.com/coreos/go-oidc/v3/oidc"
)

D internal/server/auth/authenticator.go => internal/server/auth/authenticator.go +0 -22
@@ 1,22 0,0 @@
// Phase-5 stub. Replaced by real implementation in Phase 6.
//
// This stub only satisfies the compile-time surface that Server depends on:
// an Init that does nothing and a Middleware that is the identity passthrough.
// Phase 6 replaces this file with the forward-auth + OIDC implementation.
package auth

import (
	"context"
	"net/http"
)

// Authenticator is a no-op steward service. The real Phase-6 version will
// hold OIDC verifier state and forward-auth config.
type Authenticator struct{}

// Init satisfies the steward Service contract.
func (a *Authenticator) Init(_ context.Context) error { return nil }

// Middleware returns next unchanged. Phase 6 replaces this with the
// authentication chain.
func (a *Authenticator) Middleware(next http.Handler) http.Handler { return next }

A internal/server/auth/middleware.go => internal/server/auth/middleware.go +177 -0
@@ 0,0 1,177 @@
// Package auth implements the HTTP authentication boundary for the lethe
// server. It provides two coordinated paths — forward-auth (a trusted
// reverse-proxy header) and OIDC bearer (validated against an issuer's JWKS)
// — and gates every /api/v1/* route on a shared allowlist.
//
// The Authenticator is a steward service: zero-value, configured via the
// `config:""` tag, with all setup performed in Init. It holds the lowercased
// allowlist and admin sets so the request path stays allocation-free.
//
// Resolution order (per spec):
//  1. If OIDC is enabled and the request carries `Authorization: Bearer ...`,
//     the token is verified and its username claim is the resolved user. A
//     present-but-invalid bearer is a hard 401 — never falls back to the
//     header path. This is the "fail-closed" invariant.
//  2. Otherwise, if forward-auth is enabled and the configured user header is
//     non-empty, that header value is the resolved user.
//  3. Otherwise, 401.
//
// After resolution, the user is lowercased and matched against the allowlist
// (403 on miss). The same allowlist applies regardless of which path resolved
// the user — there is no "OIDC bypasses the list" mode. The resolved user is
// stamped onto the request context (for handlers via IdentityFrom and for the
// access logger via observability.WithUser) before next.ServeHTTP.
package auth

import (
	"context"
	"net/http"
	"strings"

	"go.bigb.es/auxilia/culpa"

	"sourcecraft.dev/bigbes/lethe/internal/config"
	"sourcecraft.dev/bigbes/lethe/internal/pkg/apierror"
	"sourcecraft.dev/bigbes/lethe/internal/platform/observability"
)

// bearerPrefix is the case-sensitive prefix per RFC 6750 §2.1 that selects
// the OIDC verification path.
const bearerPrefix = "Bearer "

// Identity is the authenticated principal stored on the request context for
// downstream handlers. IsAdmin reflects membership in `auth.admins`.
type Identity struct {
	User    string
	IsAdmin bool
}

// identityKey is the unexported context key for Identity values. Using a
// dedicated zero-size type prevents collisions with values stored under
// string keys elsewhere.
type identityKey struct{}

// WithIdentity attaches id to ctx. Handlers downstream of the auth middleware
// can recover it with IdentityFrom or MustIdentity.
func WithIdentity(ctx context.Context, id Identity) context.Context {
	return context.WithValue(ctx, identityKey{}, id)
}

// IdentityFrom returns the Identity previously attached with WithIdentity.
// The boolean is false when no identity is present (i.e. the request was
// dispatched outside the authenticated /api/v1/* group).
func IdentityFrom(ctx context.Context) (Identity, bool) {
	v, ok := ctx.Value(identityKey{}).(Identity)
	return v, ok
}

// MustIdentity returns the Identity stored on ctx and panics if none is
// present. Handlers that mount under the auth middleware can rely on it; a
// missing identity is a programmer error in route mounting.
func MustIdentity(ctx context.Context) Identity {
	id, ok := IdentityFrom(ctx)
	if !ok {
		panic("auth: MustIdentity called outside authenticated route group")
	}
	return id
}

// Authenticator is the steward-managed HTTP middleware that resolves the
// caller's identity. Verifier is optional and only consulted when OIDC is
// enabled; main.go (Phase 9) is responsible for registering OIDCVerifier
// conditionally on `cfg.Auth.OIDC.Enabled`.
type Authenticator struct {
	Cfg config.AuthConfig `config:""`

	Log      *observability.Logger `inject:""`
	Verifier *OIDCVerifier         `inject:"" optional:"true"`

	allowed map[string]struct{}
	admins  map[string]struct{}
}

// Init builds the lowercased allowlist and admin sets and asserts that the
// configured paths have the dependencies they need. Forward-auth without a
// header name and OIDC without a verifier are config invariant breaches —
// both should have been caught earlier (config validator / Phase-9 wiring),
// but defense-in-depth fails closed here too.
func (a *Authenticator) Init(_ context.Context) error {
	if a.Cfg.OIDC.Enabled && a.Verifier == nil {
		return culpa.WithCode(
			culpa.New("OIDC enabled but verifier not registered"),
			"CONFIG_INVALID",
		)
	}
	if a.Cfg.ForwardAuth.Enabled && a.Cfg.ForwardAuth.UserHeader == "" {
		return culpa.WithCode(
			culpa.New("forward_auth enabled but user_header is empty"),
			"CONFIG_INVALID",
		)
	}

	a.allowed = make(map[string]struct{}, len(a.Cfg.AllowedUsers))
	for _, u := range a.Cfg.AllowedUsers {
		a.allowed[strings.ToLower(u)] = struct{}{}
	}
	a.admins = make(map[string]struct{}, len(a.Cfg.Admins))
	for _, u := range a.Cfg.Admins {
		a.admins[strings.ToLower(u)] = struct{}{}
	}
	return nil
}

// Middleware returns an http.Handler that authenticates the request before
// calling next. Failures render an RFC 7807 problem document via apierror
// and short-circuit the chain.
func (a *Authenticator) Middleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		user, err := a.resolveUser(r)
		if err != nil {
			apierror.Render(w, r, err)
			return
		}

		user = strings.ToLower(user)
		if _, ok := a.allowed[user]; !ok {
			apierror.Render(w, r, culpa.WithCode(
				culpa.New("user not in allowlist"),
				"FORBIDDEN",
			))
			return
		}

		_, isAdmin := a.admins[user]
		ctx := WithIdentity(r.Context(), Identity{User: user, IsAdmin: isAdmin})
		ctx = observability.WithUser(ctx, user)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

// resolveUser implements the bearer-then-header resolution order described
// in the package doc. A present-but-invalid bearer never falls back to the
// header path — that would silently downgrade auth and is the bedrock test
// case in the suite.
func (a *Authenticator) resolveUser(r *http.Request) (string, error) {
	if a.Cfg.OIDC.Enabled {
		authz := r.Header.Get("Authorization")
		if strings.HasPrefix(authz, bearerPrefix) {
			token := strings.TrimPrefix(authz, bearerPrefix)
			user, err := a.Verifier.Verify(r.Context(), token)
			if err != nil {
				return "", err
			}
			return user, nil
		}
	}

	if a.Cfg.ForwardAuth.Enabled {
		if v := r.Header.Get(a.Cfg.ForwardAuth.UserHeader); v != "" {
			return v, nil
		}
	}

	return "", culpa.WithCode(
		culpa.New("authentication required"),
		"UNAUTHORIZED",
	)
}

A internal/server/auth/middleware_test.go => internal/server/auth/middleware_test.go +537 -0
@@ 0,0 1,537 @@
package auth_test

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"

	"github.com/go-chi/chi/v5"

	"sourcecraft.dev/bigbes/lethe/internal/config"
	"sourcecraft.dev/bigbes/lethe/internal/platform/observability"
	"sourcecraft.dev/bigbes/lethe/internal/server/auth"
)

// problem mirrors the apierror.Problem extension fields the tests assert on.
// Decoded into a local type to avoid importing the unexported render plumbing.
type problem struct {
	Status int    `json:"status"`
	Code   string `json:"code"`
	Detail string `json:"detail"`
}

// captureHandler records the Identity it sees on the request context. It
// always responds 200 so a non-200 in the test means the auth chain rejected
// the request before reaching here.
type captureHandler struct {
	called   bool
	identity auth.Identity
	hadID    bool
}

func (c *captureHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	c.called = true
	c.identity, c.hadID = auth.IdentityFrom(r.Context())
	w.WriteHeader(http.StatusOK)
}

// newAuthenticator builds an Authenticator with a working logger and the
// supplied config + verifier, calls Init, and fails the test on Init error.
func newAuthenticator(t *testing.T, cfg config.AuthConfig, v *auth.OIDCVerifier) *auth.Authenticator {
	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)
	}
	a := &auth.Authenticator{Cfg: cfg, Log: logger, Verifier: v}
	if err := a.Init(context.Background()); err != nil {
		t.Fatalf("Authenticator.Init: %v", err)
	}
	return a
}

// mountAndServe wires the authenticator under a chi router on /api/v1, then
// dispatches r against it. Returns the recorder and the capture handler so
// the test can assert on both response and resolved identity.
func mountAndServe(a *auth.Authenticator, r *http.Request) (*httptest.ResponseRecorder, *captureHandler) {
	cap := &captureHandler{}
	router := chi.NewRouter()
	router.Route("/api/v1", func(r chi.Router) {
		r.Use(a.Middleware)
		r.Handle("/*", cap)
	})
	rec := httptest.NewRecorder()
	router.ServeHTTP(rec, r)
	return rec, cap
}

// --- Forward-auth path -----------------------------------------------------

func TestForwardAuth_MissingHeader(t *testing.T) {
	a := newAuthenticator(t, config.AuthConfig{
		AllowedUsers: []string{"alice"},
		ForwardAuth:  config.ForwardAuthConfig{Enabled: true, UserHeader: "Remote-User"},
	}, nil)

	rec, cap := mountAndServe(a, httptest.NewRequest(http.MethodGet, "/api/v1/x", nil))
	if rec.Code != http.StatusUnauthorized {
		t.Fatalf("status = %d; want 401", rec.Code)
	}
	if cap.called {
		t.Errorf("handler should not have been called")
	}
}

func TestForwardAuth_HeaderNotInAllowlist(t *testing.T) {
	a := newAuthenticator(t, config.AuthConfig{
		AllowedUsers: []string{"alice"},
		ForwardAuth:  config.ForwardAuthConfig{Enabled: true, UserHeader: "Remote-User"},
	}, nil)

	req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
	req.Header.Set("Remote-User", "mallory")

	rec, cap := mountAndServe(a, req)
	if rec.Code != http.StatusForbidden {
		t.Fatalf("status = %d; want 403", rec.Code)
	}
	if cap.called {
		t.Errorf("handler should not have been called")
	}
}

func TestForwardAuth_HeaderAllowed(t *testing.T) {
	a := newAuthenticator(t, config.AuthConfig{
		AllowedUsers: []string{"alice"},
		ForwardAuth:  config.ForwardAuthConfig{Enabled: true, UserHeader: "Remote-User"},
	}, nil)

	req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
	req.Header.Set("Remote-User", "alice")

	rec, cap := mountAndServe(a, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d; want 200", rec.Code)
	}
	if !cap.called || !cap.hadID {
		t.Fatalf("expected handler called with identity; called=%v hadID=%v", cap.called, cap.hadID)
	}
	if cap.identity.User != "alice" {
		t.Errorf("identity.User = %q; want alice", cap.identity.User)
	}
}

func TestForwardAuth_AllowlistCaseInsensitive(t *testing.T) {
	a := newAuthenticator(t, config.AuthConfig{
		AllowedUsers: []string{"Alice"},
		ForwardAuth:  config.ForwardAuthConfig{Enabled: true, UserHeader: "Remote-User"},
	}, nil)

	req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
	req.Header.Set("Remote-User", "ALICE")

	rec, cap := mountAndServe(a, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d; want 200", rec.Code)
	}
	if cap.identity.User != "alice" {
		t.Errorf("identity.User = %q; want alice (lowercased)", cap.identity.User)
	}
}

func TestForwardAuth_CustomHeaderName(t *testing.T) {
	a := newAuthenticator(t, config.AuthConfig{
		AllowedUsers: []string{"alice"},
		ForwardAuth:  config.ForwardAuthConfig{Enabled: true, UserHeader: "X-Forwarded-User"},
	}, nil)

	req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
	req.Header.Set("X-Forwarded-User", "alice")

	rec, _ := mountAndServe(a, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d; want 200", rec.Code)
	}

	// Default header name should NOT work when a custom one is configured.
	req2 := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
	req2.Header.Set("Remote-User", "alice")
	rec2, _ := mountAndServe(a, req2)
	if rec2.Code != http.StatusUnauthorized {
		t.Fatalf("Remote-User leak: status = %d; want 401", rec2.Code)
	}
}

// --- OIDC path -------------------------------------------------------------

func TestOIDC_MissingAuthorization(t *testing.T) {
	o := newOIDCTestServer(t)
	v := o.newVerifier(t, "lethe")
	a := newAuthenticator(t, config.AuthConfig{
		AllowedUsers: []string{"alice"},
		OIDC:         config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
	}, v)

	rec, _ := mountAndServe(a, httptest.NewRequest(http.MethodGet, "/api/v1/x", nil))
	if rec.Code != http.StatusUnauthorized {
		t.Fatalf("status = %d; want 401", rec.Code)
	}
}

func TestOIDC_MalformedBearer(t *testing.T) {
	o := newOIDCTestServer(t)
	v := o.newVerifier(t, "lethe")
	a := newAuthenticator(t, config.AuthConfig{
		AllowedUsers: []string{"alice"},
		OIDC:         config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
	}, v)

	req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
	req.Header.Set("Authorization", "Bearer not-a-jwt")

	rec, _ := mountAndServe(a, req)
	if rec.Code != http.StatusUnauthorized {
		t.Fatalf("status = %d; want 401", rec.Code)
	}
}

func TestOIDC_ValidAndAllowed(t *testing.T) {
	o := newOIDCTestServer(t)
	v := o.newVerifier(t, "lethe")
	a := newAuthenticator(t, config.AuthConfig{
		AllowedUsers: []string{"alice"},
		OIDC:         config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
	}, v)

	tok := o.signToken(t, map[string]any{
		"aud":                "lethe",
		"sub":                "alice-uuid",
		"preferred_username": "alice",
	})
	req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
	req.Header.Set("Authorization", "Bearer "+tok)

	rec, cap := mountAndServe(a, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d; want 200; body=%s", rec.Code, rec.Body.String())
	}
	if cap.identity.User != "alice" {
		t.Errorf("identity.User = %q; want alice", cap.identity.User)
	}
}

func TestOIDC_ValidNotAllowed(t *testing.T) {
	o := newOIDCTestServer(t)
	v := o.newVerifier(t, "lethe")
	a := newAuthenticator(t, config.AuthConfig{
		AllowedUsers: []string{"alice"},
		OIDC:         config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
	}, v)

	tok := o.signToken(t, map[string]any{
		"aud":                "lethe",
		"sub":                "mallory-uuid",
		"preferred_username": "mallory",
	})
	req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
	req.Header.Set("Authorization", "Bearer "+tok)

	rec, _ := mountAndServe(a, req)
	if rec.Code != http.StatusForbidden {
		t.Fatalf("status = %d; want 403", rec.Code)
	}
}

func TestOIDC_Expired(t *testing.T) {
	o := newOIDCTestServer(t)
	v := o.newVerifier(t, "lethe")
	a := newAuthenticator(t, config.AuthConfig{
		AllowedUsers: []string{"alice"},
		OIDC:         config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
	}, v)

	// Expired well past go-oidc's default skew.
	tok := o.signToken(t, map[string]any{
		"aud":                "lethe",
		"sub":                "alice",
		"preferred_username": "alice",
		"iat":                time.Now().Add(-2 * time.Hour).Unix(),
		"exp":                time.Now().Add(-1 * time.Hour).Unix(),
	})
	req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
	req.Header.Set("Authorization", "Bearer "+tok)

	rec, _ := mountAndServe(a, req)
	if rec.Code != http.StatusUnauthorized {
		t.Fatalf("status = %d; want 401", rec.Code)
	}
}

func TestOIDC_WrongAudience(t *testing.T) {
	o := newOIDCTestServer(t)
	v := o.newVerifier(t, "lethe")
	a := newAuthenticator(t, config.AuthConfig{
		AllowedUsers: []string{"alice"},
		OIDC:         config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
	}, v)

	tok := o.signToken(t, map[string]any{
		"aud":                "someone-else",
		"sub":                "alice",
		"preferred_username": "alice",
	})
	req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
	req.Header.Set("Authorization", "Bearer "+tok)

	rec, _ := mountAndServe(a, req)
	if rec.Code != http.StatusUnauthorized {
		t.Fatalf("status = %d; want 401", rec.Code)
	}
}

func TestOIDC_PreferredUsernameClaimUsed(t *testing.T) {
	o := newOIDCTestServer(t)
	v := o.newVerifier(t, "lethe")
	a := newAuthenticator(t, config.AuthConfig{
		AllowedUsers: []string{"alice"},
		OIDC:         config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
	}, v)

	tok := o.signToken(t, map[string]any{
		"aud":                "lethe",
		"sub":                "u-9999",
		"preferred_username": "alice",
	})
	req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
	req.Header.Set("Authorization", "Bearer "+tok)

	rec, cap := mountAndServe(a, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d; want 200", rec.Code)
	}
	if cap.identity.User != "alice" {
		t.Errorf("identity.User = %q; want alice (from preferred_username)", cap.identity.User)
	}
}

func TestOIDC_FallsBackToSubWhenPreferredUsernameAbsent(t *testing.T) {
	o := newOIDCTestServer(t)
	v := o.newVerifier(t, "lethe")
	a := newAuthenticator(t, config.AuthConfig{
		AllowedUsers: []string{"u-9999"},
		OIDC:         config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
	}, v)

	tok := o.signToken(t, map[string]any{
		"aud": "lethe",
		"sub": "u-9999",
		// no preferred_username
	})
	req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
	req.Header.Set("Authorization", "Bearer "+tok)

	rec, cap := mountAndServe(a, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d; want 200; body=%s", rec.Code, rec.Body.String())
	}
	if cap.identity.User != "u-9999" {
		t.Errorf("identity.User = %q; want u-9999 (from sub fallback)", cap.identity.User)
	}
}

// --- Both enabled ---------------------------------------------------------

func TestBoth_BearerWinsOverHeader(t *testing.T) {
	o := newOIDCTestServer(t)
	v := o.newVerifier(t, "lethe")
	a := newAuthenticator(t, config.AuthConfig{
		AllowedUsers: []string{"alice", "bob"},
		ForwardAuth:  config.ForwardAuthConfig{Enabled: true, UserHeader: "Remote-User"},
		OIDC:         config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
	}, v)

	tok := o.signToken(t, map[string]any{
		"aud":                "lethe",
		"sub":                "alice",
		"preferred_username": "alice",
	})
	req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
	req.Header.Set("Authorization", "Bearer "+tok)
	req.Header.Set("Remote-User", "bob")

	rec, cap := mountAndServe(a, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d; want 200", rec.Code)
	}
	if cap.identity.User != "alice" {
		t.Errorf("identity.User = %q; want alice (from JWT, not header)", cap.identity.User)
	}
}

func TestBoth_BearerInvalidWithHeader_FailsClosed(t *testing.T) {
	o := newOIDCTestServer(t)
	v := o.newVerifier(t, "lethe")
	a := newAuthenticator(t, config.AuthConfig{
		AllowedUsers: []string{"alice"},
		ForwardAuth:  config.ForwardAuthConfig{Enabled: true, UserHeader: "Remote-User"},
		OIDC:         config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
	}, v)

	req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
	req.Header.Set("Authorization", "Bearer not-a-jwt")
	req.Header.Set("Remote-User", "alice")

	rec, cap := mountAndServe(a, req)
	if rec.Code != http.StatusUnauthorized {
		t.Fatalf("status = %d; want 401 (no fallback to header)", rec.Code)
	}
	if cap.called {
		t.Errorf("handler should not have been called when bearer was invalid")
	}
}

func TestBoth_AbsentBearerAndHeader(t *testing.T) {
	o := newOIDCTestServer(t)
	v := o.newVerifier(t, "lethe")
	a := newAuthenticator(t, config.AuthConfig{
		AllowedUsers: []string{"alice"},
		ForwardAuth:  config.ForwardAuthConfig{Enabled: true, UserHeader: "Remote-User"},
		OIDC:         config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
	}, v)

	rec, _ := mountAndServe(a, httptest.NewRequest(http.MethodGet, "/api/v1/x", nil))
	if rec.Code != http.StatusUnauthorized {
		t.Fatalf("status = %d; want 401", rec.Code)
	}
}

// --- Admin flag -----------------------------------------------------------

func TestAdmin_AdminUserHasIsAdminTrue(t *testing.T) {
	a := newAuthenticator(t, config.AuthConfig{
		AllowedUsers: []string{"alice", "bob"},
		Admins:       []string{"alice"},
		ForwardAuth:  config.ForwardAuthConfig{Enabled: true, UserHeader: "Remote-User"},
	}, nil)

	req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
	req.Header.Set("Remote-User", "alice")

	_, cap := mountAndServe(a, req)
	if !cap.identity.IsAdmin {
		t.Errorf("IsAdmin = false; want true for alice")
	}
}

func TestAdmin_NonAdminHasIsAdminFalse(t *testing.T) {
	a := newAuthenticator(t, config.AuthConfig{
		AllowedUsers: []string{"alice", "bob"},
		Admins:       []string{"alice"},
		ForwardAuth:  config.ForwardAuthConfig{Enabled: true, UserHeader: "Remote-User"},
	}, nil)

	req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
	req.Header.Set("Remote-User", "bob")

	_, cap := mountAndServe(a, req)
	if cap.identity.IsAdmin {
		t.Errorf("IsAdmin = true; want false for bob")
	}
}

// --- Problem JSON shape ---------------------------------------------------

func TestProblemShape_401(t *testing.T) {
	a := newAuthenticator(t, config.AuthConfig{
		AllowedUsers: []string{"alice"},
		ForwardAuth:  config.ForwardAuthConfig{Enabled: true, UserHeader: "Remote-User"},
	}, nil)

	rec, _ := mountAndServe(a, httptest.NewRequest(http.MethodGet, "/api/v1/x", nil))
	if rec.Code != http.StatusUnauthorized {
		t.Fatalf("status = %d; want 401", rec.Code)
	}
	if got := rec.Header().Get("Content-Type"); got != "application/problem+json" {
		t.Errorf("Content-Type = %q; want application/problem+json", got)
	}
	var p problem
	if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
		t.Fatalf("decode body: %v; body=%s", err, rec.Body.String())
	}
	if p.Status != http.StatusUnauthorized {
		t.Errorf("body.status = %d; want 401", p.Status)
	}
	if p.Code != "UNAUTHORIZED" {
		t.Errorf("body.code = %q; want UNAUTHORIZED", p.Code)
	}
}

func TestProblemShape_403(t *testing.T) {
	a := newAuthenticator(t, config.AuthConfig{
		AllowedUsers: []string{"alice"},
		ForwardAuth:  config.ForwardAuthConfig{Enabled: true, UserHeader: "Remote-User"},
	}, nil)

	req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
	req.Header.Set("Remote-User", "mallory")

	rec, _ := mountAndServe(a, req)
	if rec.Code != http.StatusForbidden {
		t.Fatalf("status = %d; want 403", rec.Code)
	}
	if got := rec.Header().Get("Content-Type"); got != "application/problem+json" {
		t.Errorf("Content-Type = %q; want application/problem+json", got)
	}
	var p problem
	if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
		t.Fatalf("decode body: %v; body=%s", err, rec.Body.String())
	}
	if p.Status != http.StatusForbidden {
		t.Errorf("body.status = %d; want 403", p.Status)
	}
	if p.Code != "FORBIDDEN" {
		t.Errorf("body.code = %q; want FORBIDDEN", p.Code)
	}
}

// --- Init invariants ------------------------------------------------------

func TestInit_OIDCEnabledWithoutVerifier(t *testing.T) {
	logger := &observability.Logger{Cfg: config.LoggingConfig{Level: "info", Format: "json"}}
	if err := logger.Init(context.Background()); err != nil {
		t.Fatalf("logger.Init: %v", err)
	}
	a := &auth.Authenticator{
		Cfg: config.AuthConfig{
			AllowedUsers: []string{"alice"},
			OIDC:         config.OIDCConfig{Enabled: true, Issuer: "https://example.invalid", Audience: "lethe", UsernameClaim: "preferred_username"},
		},
		Log:      logger,
		Verifier: nil,
	}
	err := a.Init(context.Background())
	if err == nil {
		t.Fatalf("Init: expected error when OIDC enabled without verifier")
	}
}

func TestInit_ForwardAuthEnabledWithoutHeader(t *testing.T) {
	logger := &observability.Logger{Cfg: config.LoggingConfig{Level: "info", Format: "json"}}
	if err := logger.Init(context.Background()); err != nil {
		t.Fatalf("logger.Init: %v", err)
	}
	a := &auth.Authenticator{
		Cfg: config.AuthConfig{
			AllowedUsers: []string{"alice"},
			ForwardAuth:  config.ForwardAuthConfig{Enabled: true, UserHeader: ""},
		},
		Log: logger,
	}
	err := a.Init(context.Background())
	if err == nil {
		t.Fatalf("Init: expected error when forward-auth enabled without user_header")
	}
}

A internal/server/auth/oidc.go => internal/server/auth/oidc.go +104 -0
@@ 0,0 1,104 @@
// OIDC bearer-token verification.
//
// OIDCVerifier wraps coreos/go-oidc/v3 with the lethe conventions: zero-value
// construction, `Init` performs the discovery handshake, `Verify` returns
// culpa-coded errors. The verifier is registered as a steward service in
// main only when `cfg.Auth.OIDC.Enabled` (Phase 9). When disabled, the
// Authenticator's `Verifier` injection point is left nil and the OIDC branch
// of the middleware never fires.
//
// Init reaches out to the issuer's `/.well-known/openid-configuration` and
// caches the JWKS — a hard-fail at startup is the chosen tradeoff: lethe
// refuses to come up rather than silently accept unauthenticated requests
// later if Authelia happens to be down at boot. (Risks doc, Phase 6.)
package auth

import (
	"context"

	"github.com/coreos/go-oidc/v3/oidc"
	"go.bigb.es/auxilia/culpa"

	"sourcecraft.dev/bigbes/lethe/internal/config"
)

// subClaim is the canonical RFC 7519 subject claim name. Used as the
// fallback when the configured username claim is absent or non-string.
const subClaim = "sub"

// OIDCVerifier is the steward-managed bearer-token verifier. It is a
// zero-value type: construct with `&OIDCVerifier{Cfg: ...}` and call Init
// before Verify.
type OIDCVerifier struct {
	Cfg config.OIDCConfig `config:""`

	verifier      *oidc.IDTokenVerifier
	usernameClaim string
}

// Init performs OIDC discovery against Cfg.Issuer and constructs the JWKS-
// backed verifier. Failures are wrapped with codes that distinguish the
// discovery handshake (likely a network/issuer problem) from any other
// initialization fault.
func (v *OIDCVerifier) Init(ctx context.Context) error {
	provider, err := oidc.NewProvider(ctx, v.Cfg.Issuer)
	if err != nil {
		return culpa.WithCode(culpa.Wrap(err, "oidc discovery"), "OIDC_DISCOVERY")
	}
	v.verifier = provider.Verifier(&oidc.Config{ClientID: v.Cfg.Audience})
	v.usernameClaim = v.Cfg.UsernameClaim
	if v.usernameClaim == "" {
		// Defense in depth: the validator already requires a non-empty value
		// when OIDC is enabled, but a zero-value here would silently fall
		// through to `sub` on every token, which is not what the operator
		// asked for.
		return culpa.WithCode(
			culpa.New("oidc username_claim is empty"),
			"OIDC_INIT",
		)
	}
	return nil
}

// Verify validates raw against the cached JWKS, then extracts the username.
// The chosen username field is `claims[usernameClaim]` when present and a
// non-empty string, falling back to `claims[sub]`. Any verification failure
// — bad signature, expired, wrong audience, malformed — surfaces with code
// UNAUTHORIZED so the HTTP boundary renders 401.
func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (string, error) {
	idToken, err := v.verifier.Verify(ctx, raw)
	if err != nil {
		return "", culpa.WithCode(culpa.Wrap(err, "verify token"), "UNAUTHORIZED")
	}

	var claims map[string]any
	if err := idToken.Claims(&claims); err != nil {
		return "", culpa.WithCode(culpa.Wrap(err, "decode token claims"), "UNAUTHORIZED")
	}

	if user, ok := stringClaim(claims, v.usernameClaim); ok && user != "" {
		return user, nil
	}
	if sub, ok := stringClaim(claims, subClaim); ok && sub != "" {
		return sub, nil
	}
	return "", culpa.WithCode(
		culpa.New("token has no usable username claim"),
		"UNAUTHORIZED",
	)
}

// stringClaim pulls key from claims as a string. Non-string values (numbers,
// nested objects) are reported as missing rather than coerced — coercion
// would mask a misconfigured username_claim.
func stringClaim(claims map[string]any, key string) (string, bool) {
	v, ok := claims[key]
	if !ok {
		return "", false
	}
	s, ok := v.(string)
	if !ok {
		return "", false
	}
	return s, true
}

A internal/server/auth/oidctestserver_test.go => internal/server/auth/oidctestserver_test.go +158 -0
@@ 0,0 1,158 @@
package auth_test

import (
	"context"
	"crypto"
	"crypto/rand"
	"crypto/rsa"
	"crypto/sha256"
	"encoding/base64"
	"encoding/binary"
	"encoding/json"
	"fmt"
	"math/big"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"

	"sourcecraft.dev/bigbes/lethe/internal/config"
	"sourcecraft.dev/bigbes/lethe/internal/server/auth"
)

// oidcTestServer hosts a minimal OIDC discovery + JWKS endpoint backed by
// an in-memory RSA key. It is not a full OP — just enough surface that the
// coreos/go-oidc verifier can fetch the JWKS and validate RS256 signatures
// produced by signToken.
type oidcTestServer struct {
	srv    *httptest.Server
	key    *rsa.PrivateKey
	keyID  string
	issuer string
}

// newOIDCTestServer builds an httptest.Server serving discovery and JWKS
// keyed off a fresh 2048-bit RSA key. The caller must call Close (via
// t.Cleanup) when done.
func newOIDCTestServer(t *testing.T) *oidcTestServer {
	t.Helper()

	key, err := rsa.GenerateKey(rand.Reader, 2048)
	if err != nil {
		t.Fatalf("rsa.GenerateKey: %v", err)
	}
	o := &oidcTestServer{key: key, keyID: "test-key-1"}

	mux := http.NewServeMux()
	mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		body := map[string]any{
			"issuer":                                o.issuer,
			"jwks_uri":                              o.issuer + "/jwks",
			"authorization_endpoint":                o.issuer + "/auth",
			"token_endpoint":                        o.issuer + "/token",
			"id_token_signing_alg_values_supported": []string{"RS256"},
			"response_types_supported":              []string{"id_token"},
			"subject_types_supported":               []string{"public"},
		}
		_ = json.NewEncoder(w).Encode(body)
	})
	mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		jwk := map[string]any{
			"kty": "RSA",
			"alg": "RS256",
			"use": "sig",
			"kid": o.keyID,
			"n":   base64URLUint(key.N),
			"e":   base64URLUintFromInt(key.E),
		}
		_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{jwk}})
	})

	o.srv = httptest.NewServer(mux)
	o.issuer = o.srv.URL
	t.Cleanup(o.srv.Close)
	return o
}

// signToken produces a signed RS256 JWT with the supplied claims merged with
// issuer/aud/iat/exp defaults. Claims supplied by the caller win.
func (o *oidcTestServer) signToken(t *testing.T, claims map[string]any) string {
	t.Helper()

	header := map[string]any{
		"alg": "RS256",
		"typ": "JWT",
		"kid": o.keyID,
	}

	// Merge defaults under what the caller provided so tests can override
	// e.g. exp or aud explicitly.
	merged := map[string]any{
		"iss": o.issuer,
		"iat": time.Now().Unix(),
		"exp": time.Now().Add(time.Hour).Unix(),
	}
	for k, v := range claims {
		merged[k] = v
	}

	headerBytes, err := json.Marshal(header)
	if err != nil {
		t.Fatalf("marshal header: %v", err)
	}
	payloadBytes, err := json.Marshal(merged)
	if err != nil {
		t.Fatalf("marshal payload: %v", err)
	}

	signingInput := base64.RawURLEncoding.EncodeToString(headerBytes) +
		"." + base64.RawURLEncoding.EncodeToString(payloadBytes)
	digest := sha256.Sum256([]byte(signingInput))
	sig, err := rsa.SignPKCS1v15(rand.Reader, o.key, crypto.SHA256, digest[:])
	if err != nil {
		t.Fatalf("rsa.SignPKCS1v15: %v", err)
	}
	return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig)
}

// newVerifier constructs a real OIDCVerifier pointed at this test server,
// audience-bound to aud.
func (o *oidcTestServer) newVerifier(t *testing.T, aud string) *auth.OIDCVerifier {
	t.Helper()
	v := &auth.OIDCVerifier{
		Cfg: config.OIDCConfig{
			Enabled:       true,
			Issuer:        o.issuer,
			Audience:      aud,
			UsernameClaim: "preferred_username",
		},
	}
	if err := v.Init(context.Background()); err != nil {
		t.Fatalf("OIDCVerifier.Init: %v", err)
	}
	return v
}

// base64URLUint encodes a big.Int as base64url with no padding, per JWA
// (RFC 7518) §6.3.1.1.
func base64URLUint(n *big.Int) string {
	return base64.RawURLEncoding.EncodeToString(n.Bytes())
}

// base64URLUintFromInt encodes the public exponent (typically 65537) as the
// minimal big-endian byte representation, per JWA §6.3.1.2.
func base64URLUintFromInt(e int) string {
	if e <= 0 {
		panic(fmt.Sprintf("invalid RSA exponent: %d", e))
	}
	var buf [8]byte
	binary.BigEndian.PutUint64(buf[:], uint64(e))
	// Strip leading zero bytes so the encoding is minimal.
	i := 0
	for i < len(buf)-1 && buf[i] == 0 {
		i++
	}
	return base64.RawURLEncoding.EncodeToString(buf[i:])
}