From 80b1c095e36243a2e9035fc2367f181954a8af5b Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sat, 25 Apr 2026 23:06:36 +0300 Subject: [PATCH] feat(auth): forward-auth + OIDC bearer middleware with shared allowlist --- internal/deps/deps.go | 19 - internal/server/auth/authenticator.go | 22 - internal/server/auth/middleware.go | 177 +++++++ internal/server/auth/middleware_test.go | 537 ++++++++++++++++++++ internal/server/auth/oidc.go | 104 ++++ internal/server/auth/oidctestserver_test.go | 158 ++++++ 6 files changed, 976 insertions(+), 41 deletions(-) delete mode 100644 internal/deps/deps.go delete mode 100644 internal/server/auth/authenticator.go create mode 100644 internal/server/auth/middleware.go create mode 100644 internal/server/auth/middleware_test.go create mode 100644 internal/server/auth/oidc.go create mode 100644 internal/server/auth/oidctestserver_test.go diff --git a/internal/deps/deps.go b/internal/deps/deps.go deleted file mode 100644 index 1485220d022e7a7743b590c49c997998a62f4e7a..0000000000000000000000000000000000000000 --- a/internal/deps/deps.go +++ /dev/null @@ -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" -) diff --git a/internal/server/auth/authenticator.go b/internal/server/auth/authenticator.go deleted file mode 100644 index 1fd757d382d74d99146bbf0df97aed71f186ed20..0000000000000000000000000000000000000000 --- a/internal/server/auth/authenticator.go +++ /dev/null @@ -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 } diff --git a/internal/server/auth/middleware.go b/internal/server/auth/middleware.go new file mode 100644 index 0000000000000000000000000000000000000000..4b6d512c9b17819340ccb1a3bd4f5033f29fc6b0 --- /dev/null +++ b/internal/server/auth/middleware.go @@ -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", + ) +} diff --git a/internal/server/auth/middleware_test.go b/internal/server/auth/middleware_test.go new file mode 100644 index 0000000000000000000000000000000000000000..665cdca8edf4528a5fdd23e6be30ed1cbe9ca312 --- /dev/null +++ b/internal/server/auth/middleware_test.go @@ -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") + } +} diff --git a/internal/server/auth/oidc.go b/internal/server/auth/oidc.go new file mode 100644 index 0000000000000000000000000000000000000000..81855ee659fbfaf71f331da016c67fe0d198bc0a --- /dev/null +++ b/internal/server/auth/oidc.go @@ -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 +} diff --git a/internal/server/auth/oidctestserver_test.go b/internal/server/auth/oidctestserver_test.go new file mode 100644 index 0000000000000000000000000000000000000000..4abb489fe6fc750140f3ac3f90d2d53ee6ce13fa --- /dev/null +++ b/internal/server/auth/oidctestserver_test.go @@ -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:]) +}