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" "sourcecraft.dev/bigbes/lethe/internal/testutil/oidcstub" ) // 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 } // newStub creates an oidcstub.Stub backed by an httptest.Server. The server is // closed on test cleanup. The Stub's issuer is set to the server URL. func newStub(t *testing.T) *oidcstub.Stub { t.Helper() stub, err := oidcstub.New(oidcstub.Options{Issuer: "http://placeholder"}) if err != nil { t.Fatalf("oidcstub.New: %v", err) } srv := httptest.NewServer(stub.Handler()) t.Cleanup(srv.Close) stub.SetIssuer(srv.URL) return stub } // signToken signs a token with the stub and fails the test on error. func signToken(t *testing.T, stub *oidcstub.Stub, claims map[string]any) string { t.Helper() tok, err := stub.SignToken(claims) if err != nil { t.Fatalf("oidcstub.SignToken: %v", err) } return tok } // newOIDCVerifier constructs a real OIDCVerifier pointed at the stub server, // audience-bound to aud. func newOIDCVerifier(t *testing.T, stub *oidcstub.Stub, aud string) *auth.OIDCVerifier { t.Helper() v := &auth.OIDCVerifier{ Cfg: config.OIDCConfig{ Enabled: true, Issuer: stub.Issuer(), Audience: aud, UsernameClaim: "preferred_username", }, } if err := v.Init(context.Background()); err != nil { t.Fatalf("OIDCVerifier.Init: %v", err) } return v } // --- 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) { stub := newStub(t) v := newOIDCVerifier(t, stub, "lethe") a := newAuthenticator(t, config.AuthConfig{ AllowedUsers: []string{"alice"}, OIDC: config.OIDCConfig{Enabled: true, Issuer: stub.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) { stub := newStub(t) v := newOIDCVerifier(t, stub, "lethe") a := newAuthenticator(t, config.AuthConfig{ AllowedUsers: []string{"alice"}, OIDC: config.OIDCConfig{Enabled: true, Issuer: stub.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) { stub := newStub(t) v := newOIDCVerifier(t, stub, "lethe") a := newAuthenticator(t, config.AuthConfig{ AllowedUsers: []string{"alice"}, OIDC: config.OIDCConfig{Enabled: true, Issuer: stub.Issuer(), Audience: "lethe", UsernameClaim: "preferred_username"}, }, v) tok := signToken(t, stub, 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) { stub := newStub(t) v := newOIDCVerifier(t, stub, "lethe") a := newAuthenticator(t, config.AuthConfig{ AllowedUsers: []string{"alice"}, OIDC: config.OIDCConfig{Enabled: true, Issuer: stub.Issuer(), Audience: "lethe", UsernameClaim: "preferred_username"}, }, v) tok := signToken(t, stub, 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) { stub := newStub(t) v := newOIDCVerifier(t, stub, "lethe") a := newAuthenticator(t, config.AuthConfig{ AllowedUsers: []string{"alice"}, OIDC: config.OIDCConfig{Enabled: true, Issuer: stub.Issuer(), Audience: "lethe", UsernameClaim: "preferred_username"}, }, v) // Expired well past go-oidc's default skew. tok := signToken(t, stub, 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) { stub := newStub(t) v := newOIDCVerifier(t, stub, "lethe") a := newAuthenticator(t, config.AuthConfig{ AllowedUsers: []string{"alice"}, OIDC: config.OIDCConfig{Enabled: true, Issuer: stub.Issuer(), Audience: "lethe", UsernameClaim: "preferred_username"}, }, v) tok := signToken(t, stub, 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) { stub := newStub(t) v := newOIDCVerifier(t, stub, "lethe") a := newAuthenticator(t, config.AuthConfig{ AllowedUsers: []string{"alice"}, OIDC: config.OIDCConfig{Enabled: true, Issuer: stub.Issuer(), Audience: "lethe", UsernameClaim: "preferred_username"}, }, v) tok := signToken(t, stub, 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) { stub := newStub(t) v := newOIDCVerifier(t, stub, "lethe") a := newAuthenticator(t, config.AuthConfig{ AllowedUsers: []string{"u-9999"}, OIDC: config.OIDCConfig{Enabled: true, Issuer: stub.Issuer(), Audience: "lethe", UsernameClaim: "preferred_username"}, }, v) tok := signToken(t, stub, 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) { stub := newStub(t) v := newOIDCVerifier(t, stub, "lethe") a := newAuthenticator(t, config.AuthConfig{ AllowedUsers: []string{"alice", "bob"}, ForwardAuth: config.ForwardAuthConfig{Enabled: true, UserHeader: "Remote-User"}, OIDC: config.OIDCConfig{Enabled: true, Issuer: stub.Issuer(), Audience: "lethe", UsernameClaim: "preferred_username"}, }, v) tok := signToken(t, stub, 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) { stub := newStub(t) v := newOIDCVerifier(t, stub, "lethe") a := newAuthenticator(t, config.AuthConfig{ AllowedUsers: []string{"alice"}, ForwardAuth: config.ForwardAuthConfig{Enabled: true, UserHeader: "Remote-User"}, OIDC: config.OIDCConfig{Enabled: true, Issuer: stub.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) { stub := newStub(t) v := newOIDCVerifier(t, stub, "lethe") a := newAuthenticator(t, config.AuthConfig{ AllowedUsers: []string{"alice"}, ForwardAuth: config.ForwardAuthConfig{Enabled: true, UserHeader: "Remote-User"}, OIDC: config.OIDCConfig{Enabled: true, Issuer: stub.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") } }