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/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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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"
|
"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)
|
||||||
|
|||||||
Reference in New Issue
Block a user