feat: add JWT validation against Authentik JWKS for protected adapters
CI / Vet, test, build (push) Successful in 2m4s
CI / Build and push image (push) Successful in 50s

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:
Admin Bot
2026-08-27 11:07:20 -07:00
parent 46dc24a26c
commit df33203a72
4 changed files with 238 additions and 19 deletions
+1
View File
@@ -11,6 +11,7 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect
github.com/gogo/protobuf v1.3.2 // 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/golang/mock v1.6.0 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect
+2
View File
@@ -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/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 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 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 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+198
View File
@@ -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
}
+37 -19
View File
@@ -8,21 +8,29 @@ import (
"net/url" "net/url"
"time" "time"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/problem" "forgejo.riotpiao.com/rock/homelab-frontend/internal/problem"
) )
// Dispatcher handles X-Service based routing to service adapters. // Dispatcher handles X-Service based routing to service adapters.
type Dispatcher struct { type Dispatcher struct {
registry *Registry registry *Registry
// authValidator would check capabilities if internal/auth exists validators map[string]*auth.Validator // Per-service JWT validators
// For now, we stub it
} }
// NewDispatcher creates a new service adapter dispatcher. // NewDispatcher creates a new service adapter dispatcher with Authentik JWT validators.
func NewDispatcher(registry *Registry) *Dispatcher { func NewDispatcher(registry *Registry) *Dispatcher {
return &Dispatcher{ dispatcher := &Dispatcher{
registry: registry, 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. // 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 return
} }
// Check auth requirements (stub for now — internal/auth integration in 8.3) // Check auth requirements using JWT validation
// Determine required capability
requiredCapability := "" requiredCapability := ""
auth := resource.Auth auth := resource.Auth
if auth == nil { if auth == nil {
@@ -98,11 +105,28 @@ func (d *Dispatcher) Dispatch(w http.ResponseWriter, r *http.Request) {
if auth != nil && auth.Required && auth.Capability != "" { if auth != nil && auth.Required && auth.Capability != "" {
requiredCapability = 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 // Validate JWT token
if !d.hasCapability(r, requiredCapability) { 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", 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) _ = p.Write(w)
return return
} }
@@ -140,13 +164,7 @@ func (d *Dispatcher) Dispatch(w http.ResponseWriter, r *http.Request) {
proxy.ServeHTTP(w, r) 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) { func (d *Dispatcher) writeError(w http.ResponseWriter, p *problem.Problem) {
_ = p.Write(w) _ = p.Write(w)