feat(auth): wire JWT validation into /v1/* LLM endpoints
CI / Vet, test, build (push) Successful in 3m52s
CI / Build and push image (push) Successful in 1m17s

This commit is contained in:
Admin Bot
2026-08-31 23:01:59 -07:00
parent f9addf945d
commit 14cc67833c
5 changed files with 141 additions and 29 deletions
+23 -2
View File
@@ -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
}
+40 -14
View File
@@ -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.
@@ -28,6 +38,7 @@ type rawModel struct {
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,
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
}
+7 -7
View File
@@ -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)
}
+57
View File
@@ -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.
+8
View File
@@ -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: []