From 14cc67833cd9bd02a9123e32494c81a18bd6b61e Mon Sep 17 00:00:00 2001 From: Admin Bot Date: Mon, 31 Aug 2026 23:01:59 -0700 Subject: [PATCH] feat(auth): wire JWT validation into /v1/* LLM endpoints --- internal/config/config.go | 25 +++++++++++-- internal/config/loader.go | 66 +++++++++++++++++++++++----------- internal/config/models_test.go | 14 ++++---- internal/proxy/proxy.go | 57 +++++++++++++++++++++++++++++ k8s/configmap.yaml | 8 +++++ 5 files changed, 141 insertions(+), 29 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index c2bb03c..fcb31b4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,6 +22,8 @@ type Config struct { Models map[string]*ModelUpstream // Adapters holds service adapter definitions for X-Service routing. Adapters []*serviceadapter.ServiceAdapter + // Auth holds JWT authentication configuration for /v1/* endpoints. + Auth AuthConfig } // ModelUpstream holds upstream configuration for a specific model. @@ -32,6 +34,22 @@ type ModelUpstream struct { Address string // Path is the upstream path for this model (e.g., "/v1/chat/completions"). Path string + // AuthRequired indicates whether this model requires JWT authentication. + AuthRequired bool +} + +// AuthConfig holds JWT authentication configuration. +type AuthConfig struct { + // Enabled globally enables/disables auth for /v1/* endpoints. + Enabled bool + // Issuer is the expected JWT issuer (iss claim). + Issuer string + // Audience is the expected JWT audience (aud claim). + Audience string + // JWKSURL is the URL to fetch JSON Web Key Set for signature validation. + JWKSURL string + // RequiredCapability is the permission required for LLM inference (e.g., "llm:inference"). + RequiredCapability string } // Route represents a single route and its upstream configuration. @@ -98,18 +116,20 @@ func Load() (*Config, error) { shutdownTimeout = d } - // Load routes, models, and adapters from config file + // Load routes, models, adapters, and auth from config file routes := make(map[string]*Route) models := make(map[string]*ModelUpstream) var adapters []*serviceadapter.ServiceAdapter + var authConfig AuthConfig if configPath, ok := os.LookupEnv("CONFIG_PATH"); ok { - loadedRoutes, loadedModels, loadedAdapters, err := LoadRoutesAndModelsFromFile(configPath) + loadedRoutes, loadedModels, loadedAdapters, loadedAuth, err := LoadRoutesAndModelsFromFile(configPath) if err != nil { return nil, err } routes = loadedRoutes models = loadedModels adapters = loadedAdapters + authConfig = loadedAuth } return &Config{ @@ -118,5 +138,6 @@ func Load() (*Config, error) { Routes: routes, Models: models, Adapters: adapters, + Auth: authConfig, }, nil } diff --git a/internal/config/loader.go b/internal/config/loader.go index f9c5614..e8cc51b 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -15,6 +15,16 @@ type rawConfig struct { Routes []rawRoute `yaml:"routes"` Models []rawModel `yaml:"models"` Adapters []rawAdapter `yaml:"adapters"` + Auth rawAuth `yaml:"auth"` +} + +// rawAuth represents auth configuration in YAML. +type rawAuth struct { + Enabled bool `yaml:"enabled"` + Issuer string `yaml:"issuer"` + Audience string `yaml:"audience"` + JWKSURL string `yaml:"jwksUrl"` + RequiredCapability string `yaml:"requiredCapability"` } // rawRoute represents a single route in the YAML configuration. @@ -25,9 +35,10 @@ type rawRoute struct { // rawModel represents a single model entry in the YAML configuration. type rawModel struct { - Name string `yaml:"name"` - Address string `yaml:"address"` - Path string `yaml:"path"` + Name string `yaml:"name"` + Address string `yaml:"address"` + Path string `yaml:"path"` + AuthRequired *bool `yaml:"authRequired"` } // rawAdapter represents a service adapter in the YAML configuration. @@ -64,32 +75,32 @@ type rawUpstream struct { AuthRequired *bool `yaml:"authRequired"` } -// LoadRoutesAndModelsFromFile loads route, model, and adapter configuration from a YAML file. -func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*ModelUpstream, []*serviceadapter.ServiceAdapter, error) { +// LoadRoutesAndModelsFromFile loads route, model, adapter, and auth configuration from a YAML file. +func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*ModelUpstream, []*serviceadapter.ServiceAdapter, AuthConfig, error) { data, err := os.ReadFile(path) if err != nil { - return nil, nil, nil, fmt.Errorf("failed to read config file %q: %w", path, err) + return nil, nil, nil, AuthConfig{}, fmt.Errorf("failed to read config file %q: %w", path, err) } var raw rawConfig if err := yaml.Unmarshal(data, &raw); err != nil { - return nil, nil, nil, fmt.Errorf("failed to parse config file %q: %w", path, err) + return nil, nil, nil, AuthConfig{}, fmt.Errorf("failed to parse config file %q: %w", path, err) } // Load routes routes := make(map[string]*Route) for _, rawRoute := range raw.Routes { if rawRoute.Name == "" { - return nil, nil, nil, fmt.Errorf("route has empty name") + return nil, nil, nil, AuthConfig{}, fmt.Errorf("route has empty name") } if _, exists := routes[rawRoute.Name]; exists { - return nil, nil, nil, fmt.Errorf("duplicate route: \"%s\"", rawRoute.Name) + return nil, nil, nil, AuthConfig{}, fmt.Errorf("duplicate route: \"%s\"", rawRoute.Name) } upstream, err := parseUpstream(rawRoute.Name, rawRoute.Upstream) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, AuthConfig{}, err } routes[rawRoute.Name] = &Route{ @@ -102,21 +113,27 @@ func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*Mo models := make(map[string]*ModelUpstream) for _, rawModel := range raw.Models { if rawModel.Name == "" { - return nil, nil, nil, fmt.Errorf("model has empty name") + return nil, nil, nil, AuthConfig{}, fmt.Errorf("model has empty name") } if _, exists := models[rawModel.Name]; exists { - return nil, nil, nil, fmt.Errorf("duplicate model: \"%s\"", rawModel.Name) + return nil, nil, nil, AuthConfig{}, fmt.Errorf("duplicate model: \"%s\"", rawModel.Name) } if rawModel.Address == "" { - return nil, nil, nil, fmt.Errorf("model \"%s\": field 'address' is required", rawModel.Name) + return nil, nil, nil, AuthConfig{}, fmt.Errorf("model \"%s\": field 'address' is required", rawModel.Name) } if _, _, err := net.SplitHostPort(rawModel.Address); err != nil { - return nil, nil, nil, fmt.Errorf("model \"%s\": invalid address \"%s\": %w", rawModel.Name, rawModel.Address, err) + return nil, nil, nil, AuthConfig{}, fmt.Errorf("model \"%s\": invalid address \"%s\": %w", rawModel.Name, rawModel.Address, err) + } + // Default authRequired to global auth.enabled if not specified per-model + authRequired := false + if rawModel.AuthRequired != nil { + authRequired = *rawModel.AuthRequired } models[rawModel.Name] = &ModelUpstream{ - Name: rawModel.Name, - Address: rawModel.Address, - Path: rawModel.Path, + Name: rawModel.Name, + Address: rawModel.Address, + Path: rawModel.Path, + AuthRequired: authRequired, } } @@ -124,7 +141,7 @@ func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*Mo adapters := make([]*serviceadapter.ServiceAdapter, 0, len(raw.Adapters)) for _, ra := range raw.Adapters { if ra.ServiceName == "" { - return nil, nil, nil, fmt.Errorf("adapter has empty serviceName") + return nil, nil, nil, AuthConfig{}, fmt.Errorf("adapter has empty serviceName") } a := &serviceadapter.ServiceAdapter{ Name: ra.ServiceName, @@ -152,13 +169,22 @@ func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*Mo adapters = append(adapters, a) } - return routes, models, adapters, nil + // Parse auth config + authConfig := AuthConfig{ + Enabled: raw.Auth.Enabled, + Issuer: raw.Auth.Issuer, + Audience: raw.Auth.Audience, + JWKSURL: raw.Auth.JWKSURL, + RequiredCapability: raw.Auth.RequiredCapability, + } + + return routes, models, adapters, authConfig, nil } // LoadRoutesFromFile loads route configuration from a YAML file. // Deprecated: Use LoadRoutesAndModelsFromFile instead. func LoadRoutesFromFile(path string) (map[string]*Route, error) { - routes, _, _, err := LoadRoutesAndModelsFromFile(path) + routes, _, _, _, err := LoadRoutesAndModelsFromFile(path) return routes, err } diff --git a/internal/config/models_test.go b/internal/config/models_test.go index e926e8c..8a5da54 100644 --- a/internal/config/models_test.go +++ b/internal/config/models_test.go @@ -35,7 +35,7 @@ models: tmpFile.WriteString(data) tmpFile.Close() - _, models, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name()) + _, models, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name()) if err != nil { t.Fatalf("failed to load config: %v", err) } @@ -94,7 +94,7 @@ models: tmpFile.WriteString(data) tmpFile.Close() - _, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name()) + _, _, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name()) if err == nil { t.Errorf("expected error for duplicate model name, got nil") } @@ -127,7 +127,7 @@ models: tmpFile.WriteString(data) tmpFile.Close() - _, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name()) + _, _, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name()) if err == nil { t.Errorf("expected error for empty model name, got nil") } @@ -157,7 +157,7 @@ models: tmpFile.WriteString(data) tmpFile.Close() - _, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name()) + _, _, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name()) if err == nil { t.Errorf("expected error for missing address, got nil") } @@ -190,7 +190,7 @@ models: tmpFile.WriteString(data) tmpFile.Close() - _, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name()) + _, _, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name()) if err == nil { t.Errorf("expected error for invalid address, got nil") } @@ -219,7 +219,7 @@ models: tmpFile.WriteString(data) tmpFile.Close() - _, models, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name()) + _, models, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name()) if err != nil { t.Fatalf("failed to load config: %v", err) } @@ -261,7 +261,7 @@ models: tmpFile.WriteString(data) tmpFile.Close() - routes, models, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name()) + routes, models, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name()) if err != nil { t.Fatalf("failed to load config: %v", err) } diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 56f9344..045e79d 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "forgejo.riotpiao.com/rock/homelab-frontend/internal/auth" "forgejo.riotpiao.com/rock/homelab-frontend/internal/config" "forgejo.riotpiao.com/rock/homelab-frontend/internal/logging" "forgejo.riotpiao.com/rock/homelab-frontend/internal/tracing" @@ -25,6 +26,8 @@ type Handler struct { transports map[string]*http.Transport // config holds the gateway configuration (for model registry, etc.) config *config.Config + // jwtValidator validates JWT tokens for authenticated endpoints + jwtValidator *auth.Validator // Default timeouts for synthesized routes (model-based dispatch) defaultConnectTimeout time.Duration defaultReadTimeout time.Duration @@ -73,6 +76,15 @@ func New(cfg *config.Config) *Handler { defaultMaxBodySize: 100 * 1024 * 1024, } + // Initialize JWT validator if auth is enabled + if cfg.Auth.Enabled && cfg.Auth.JWKSURL != "" { + h.jwtValidator = auth.NewValidator( + cfg.Auth.Issuer, + cfg.Auth.Audience, + cfg.Auth.JWKSURL, + ) + } + for name, route := range cfg.Routes { // Create a transport per unique upstream address for connection reuse transport := h.getOrCreateTransport(route.Upstream.Address, &route.Upstream) @@ -291,6 +303,51 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + // JWT Authentication for /v1/* endpoints + if h.jwtValidator != nil && strings.HasPrefix(r.URL.Path, "/v1/") { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + writeProblemDetail(w, http.StatusUnauthorized, + "https://api.example.com/problems/unauthorized", + "Unauthorized", + "Authorization header required", + nil) + logging.Errorf("auth failed", fmt.Errorf("missing auth header"), map[string]string{ + "path": r.URL.Path, + }) + return + } + + claims, err := h.jwtValidator.ValidateBearerToken(authHeader) + if err != nil { + writeProblemDetail(w, http.StatusForbidden, + "https://api.example.com/problems/forbidden", + "Forbidden", + fmt.Sprintf("JWT validation failed: %v", err), + nil) + logging.Errorf("auth failed", err, map[string]string{ + "path": r.URL.Path, + }) + return + } + + // Check required capability if configured + if h.config.Auth.RequiredCapability != "" { + if !h.jwtValidator.CheckPermissions(claims, h.config.Auth.RequiredCapability, "*") { + writeProblemDetail(w, http.StatusForbidden, + "https://api.example.com/problems/insufficient-permissions", + "Insufficient Permissions", + fmt.Sprintf("Required capability: %s", h.config.Auth.RequiredCapability), + nil) + logging.Errorf("auth failed", fmt.Errorf("insufficient permissions"), map[string]string{ + "path": r.URL.Path, + "required": h.config.Auth.RequiredCapability, + }) + return + } + } + } + // Note: Body size checking already happened in RouteRequest (body was read for model dispatch). // For other paths, we still need to enforce the cap. // For /v1/chat/completions, the body was already read and validated. diff --git a/k8s/configmap.yaml b/k8s/configmap.yaml index b3fe2a7..a521497 100644 --- a/k8s/configmap.yaml +++ b/k8s/configmap.yaml @@ -10,6 +10,14 @@ data: # Gateway configuration - loaded at startup, never compiled in # See REQUIREMENTS.md for full specification + # JWT Authentication for /v1/* endpoints (LLM API) + auth: + enabled: true + issuer: "https://authentik.riotpiao.com/application/o/local-llm/" + audience: "local-llm" + jwksUrl: "https://authentik.riotpiao.com/application/o/local-llm/jwks/" + requiredCapability: "llm:inference" + # Routes: standard HTTP proxy routes (not LLM-specific) # These are for non-LLM services (agent-pod/console, etc.) routes: []