feat(auth): wire JWT validation into /v1/* LLM endpoints
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
+46
-20
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user