feat: add JWT validation against Authentik JWKS for protected adapters
Replaces stub 'check Authorization header' auth with real JWT validation: - Extracts Bearer token from Authorization header - Validates signature against Authentik JWKS endpoint - Verifies iss, aud, exp claims - Checks permissions claim for required capability - Handles key rotation with 15min cache TTL - Returns 403 with detailed error on auth failure Protected adapters (memory, iam) now require valid Authentik JWT tokens.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 <token>"
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
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{
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user