diff --git a/go.mod b/go.mod index 70e0f16..85e25c9 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang/mock v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect diff --git a/go.sum b/go.sum index 44f93c9..77506c1 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..f02220d --- /dev/null +++ b/internal/auth/jwt.go @@ -0,0 +1,198 @@ +package auth + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// Validator validates JWT tokens from Authentik. +type Validator struct { + issuer string + audience string + jwksURL string + client *http.Client + + mu sync.RWMutex + keyset *keySet + lastFetch time.Time + cacheTTL time.Duration +} + +type keySet struct { + Keys map[string]interface{} `json:"keys"` +} + +// Claims holds Authentik JWT claims. +type Claims struct { + jwt.RegisteredClaims + Permissions []string `json:"permissions"` + Groups []string `json:"groups"` +} + +// NewValidator creates a validator for an Authentik app. +// appSlug is the OAuth2 provider slug (e.g., "gateway", "sqs", "memory"). +func NewValidator(appSlug string) *Validator { + issuer := fmt.Sprintf("https://authentik.riotpiao.com/application/o/%s/", appSlug) + return &Validator{ + issuer: issuer, + audience: appSlug, + jwksURL: issuer + "jwks/", + client: &http.Client{Timeout: 10 * time.Second}, + cacheTTL: 15 * time.Minute, + } +} + +// ValidateToken extracts and validates a JWT from the Authorization header. +// Returns the claims if valid, or an error if invalid/missing. +func (v *Validator) ValidateToken(authHeader string) (*Claims, error) { + // Extract token from "Bearer " + if authHeader == "" { + return nil, fmt.Errorf("authorization header missing") + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return nil, fmt.Errorf("invalid authorization header format") + } + + tokenString := parts[1] + + // Ensure JWKS is fresh + if err := v.ensureKeys(); err != nil { + return nil, fmt.Errorf("failed to fetch JWKS: %w", err) + } + + // Parse JWT with custom key func + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + // Verify algorithm is RS256 only + if token.Method.Alg() != "RS256" { + return nil, fmt.Errorf("unexpected algorithm: %v", token.Header["alg"]) + } + + kid, ok := token.Header["kid"].(string) + if !ok { + return nil, fmt.Errorf("kid not in token header") + } + + // Get key from cache + v.mu.RLock() + keys := v.keyset.Keys + v.mu.RUnlock() + + rawKey, exists := keys[kid] + if !exists { + // Try refreshing JWKS (key rotation) + if err := v.fetchKeys(); err == nil { + v.mu.RLock() + rawKey, exists = v.keyset.Keys[kid] + v.mu.RUnlock() + } + if !exists { + return nil, fmt.Errorf("key %s not found", kid) + } + } + + return rawKey, nil + }) + + if err != nil { + return nil, fmt.Errorf("token validation failed: %w", err) + } + + if !token.Valid { + return nil, fmt.Errorf("token invalid") + } + + claims, ok := token.Claims.(*Claims) + if !ok { + return nil, fmt.Errorf("invalid claims") + } + + // Verify issuer + if claims.Issuer != v.issuer { + return nil, fmt.Errorf("issuer mismatch") + } + + // Verify audience (check if audience is in the claims) + if len(claims.Audience) == 0 { + return nil, fmt.Errorf("no audience in token") + } + audFound := false + for _, aud := range claims.Audience { + if aud == v.audience { + audFound = true + break + } + } + if !audFound { + return nil, fmt.Errorf("audience mismatch: expected %s", v.audience) + } + + // Verify exp, nbf, iat + now := time.Now().Unix() + if claims.ExpiresAt != nil && claims.ExpiresAt.Unix() < now { + return nil, fmt.Errorf("token expired") + } + if claims.NotBefore != nil && claims.NotBefore.Unix() > now+60 { + return nil, fmt.Errorf("token not yet valid") + } + + return claims, nil +} + +// HasPermission checks if claims contain the required permission. +// Wildcard "*" grants all permissions. +func (v *Validator) HasPermission(claims *Claims, permission string) bool { + for _, p := range claims.Permissions { + if p == "*" || p == permission { + return true + } + } + return false +} + +// ensureKeys refreshes JWKS if cache is stale. +func (v *Validator) ensureKeys() error { + v.mu.RLock() + cacheValid := v.keyset != nil && time.Since(v.lastFetch) < v.cacheTTL + v.mu.RUnlock() + + if cacheValid { + return nil + } + + return v.fetchKeys() +} + +// fetchKeys fetches JWKS from Authentik. +func (v *Validator) fetchKeys() error { + resp, err := v.client.Get(v.jwksURL) + if err != nil { + return fmt.Errorf("GET %s failed: %w", v.jwksURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("JWKS endpoint returned %d: %s", resp.StatusCode, string(body)) + } + + var ks keySet + if err := json.NewDecoder(resp.Body).Decode(&ks); err != nil { + return fmt.Errorf("decode JWKS failed: %w", err) + } + + v.mu.Lock() + v.keyset = &ks + v.lastFetch = time.Now() + v.mu.Unlock() + + return nil +} diff --git a/internal/serviceadapter/router.go b/internal/serviceadapter/router.go index 01bf2cb..70f3af2 100644 --- a/internal/serviceadapter/router.go +++ b/internal/serviceadapter/router.go @@ -8,21 +8,29 @@ import ( "net/url" "time" + "forgejo.riotpiao.com/rock/homelab-frontend/internal/auth" "forgejo.riotpiao.com/rock/homelab-frontend/internal/problem" ) // Dispatcher handles X-Service based routing to service adapters. type Dispatcher struct { - registry *Registry - // authValidator would check capabilities if internal/auth exists - // For now, we stub it + registry *Registry + validators map[string]*auth.Validator // Per-service JWT validators } -// NewDispatcher creates a new service adapter dispatcher. +// NewDispatcher creates a new service adapter dispatcher with Authentik JWT validators. func NewDispatcher(registry *Registry) *Dispatcher { - return &Dispatcher{ - registry: registry, + dispatcher := &Dispatcher{ + registry: registry, + validators: make(map[string]*auth.Validator), } + + // Create validators for all registered services + for _, adapter := range registry.List() { + dispatcher.validators[adapter.ServiceName] = auth.NewValidator(adapter.ServiceName) + } + + return dispatcher } // Matches returns true if the request should be dispatched based on X-Service header. @@ -85,8 +93,7 @@ func (d *Dispatcher) Dispatch(w http.ResponseWriter, r *http.Request) { return } - // Check auth requirements (stub for now — internal/auth integration in 8.3) - // Determine required capability + // Check auth requirements using JWT validation requiredCapability := "" auth := resource.Auth if auth == nil { @@ -98,11 +105,28 @@ func (d *Dispatcher) Dispatch(w http.ResponseWriter, r *http.Request) { if auth != nil && auth.Required && auth.Capability != "" { requiredCapability = auth.Capability - // Would validate JWT and capability here (depends on internal/auth) - // For now, stub — just log that it would be checked - if !d.hasCapability(r, requiredCapability) { + + // Validate JWT token + validator := d.validators[serviceName] + if validator == nil { + p := problem.NewProblem(http.StatusInternalServerError, "about:blank#server-error", + "Internal Server Error", fmt.Sprintf("no validator for service '%s'", serviceName)) + _ = p.Write(w) + return + } + + claims, err := validator.ValidateToken(r.Header.Get("Authorization")) + if err != nil { p := problem.NewProblem(http.StatusForbidden, "about:blank#forbidden", - "Forbidden", fmt.Sprintf("capability '%s' required", requiredCapability)) + "Forbidden", fmt.Sprintf("authentication failed: %v", err)) + _ = p.Write(w) + return + } + + // Check permission + if !validator.HasPermission(claims, requiredCapability) { + p := problem.NewProblem(http.StatusForbidden, "about:blank#forbidden", + "Forbidden", fmt.Sprintf("permission '%s' required", requiredCapability)) _ = p.Write(w) return } @@ -140,13 +164,7 @@ func (d *Dispatcher) Dispatch(w http.ResponseWriter, r *http.Request) { proxy.ServeHTTP(w, r) } -// hasCapability checks if the request has the required capability. -// Stub implementation — depends on internal/auth JWT validation. -func (d *Dispatcher) hasCapability(r *http.Request, capability string) bool { - // TODO: Parse JWT from Authorization header and check capabilities - // For now, assume all authenticated requests have all capabilities - return r.Header.Get("Authorization") != "" -} + func (d *Dispatcher) writeError(w http.ResponseWriter, p *problem.Problem) { _ = p.Write(w)