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.
172 lines
4.6 KiB
Go
172 lines
4.6 KiB
Go
package serviceadapter
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httputil"
|
|
"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
|
|
validators map[string]*auth.Validator // Per-service JWT validators
|
|
}
|
|
|
|
// NewDispatcher creates a new service adapter dispatcher with Authentik JWT validators.
|
|
func NewDispatcher(registry *Registry) *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.
|
|
func (d *Dispatcher) Matches(r *http.Request) bool {
|
|
return r.Header.Get("X-Service") != ""
|
|
}
|
|
|
|
// Dispatch routes a request to the appropriate adapter.
|
|
// Returns a problem document if the adapter or resource is not found.
|
|
func (d *Dispatcher) Dispatch(w http.ResponseWriter, r *http.Request) {
|
|
serviceName := r.Header.Get("X-Service")
|
|
if serviceName == "" {
|
|
// No X-Service header — this shouldn't happen if Matches() was called
|
|
d.writeError(w, problem.BadRequest("X-Service header required"))
|
|
return
|
|
}
|
|
|
|
// Look up service adapter
|
|
adapter := d.registry.Get(serviceName)
|
|
if adapter == nil {
|
|
p := problem.NotFound(fmt.Sprintf("service '%s' not found", serviceName))
|
|
_ = p.Write(w)
|
|
return
|
|
}
|
|
|
|
// Get resource and method from request
|
|
resourceName := r.Header.Get("X-Resource")
|
|
if resourceName == "" {
|
|
d.writeError(w, problem.BadRequest("X-Resource header required"))
|
|
return
|
|
}
|
|
|
|
// Find resource
|
|
var resource *Resource
|
|
for i := range adapter.Spec.Resources {
|
|
if adapter.Spec.Resources[i].Name == resourceName {
|
|
resource = &adapter.Spec.Resources[i]
|
|
break
|
|
}
|
|
}
|
|
|
|
if resource == nil {
|
|
p := problem.NotFound(fmt.Sprintf("resource '%s' not found in service '%s'", resourceName, serviceName))
|
|
_ = p.Write(w)
|
|
return
|
|
}
|
|
|
|
// Find method matching HTTP verb
|
|
var method *Method
|
|
for i := range resource.Methods {
|
|
if resource.Methods[i].Verb == r.Method {
|
|
method = &resource.Methods[i]
|
|
break
|
|
}
|
|
}
|
|
|
|
if method == nil {
|
|
p := problem.NotFound(fmt.Sprintf("method %s not defined for resource '%s'", r.Method, resourceName))
|
|
_ = p.Write(w)
|
|
return
|
|
}
|
|
|
|
// Check auth requirements using JWT validation
|
|
requiredCapability := ""
|
|
auth := resource.Auth
|
|
if auth == nil {
|
|
auth = &adapter.Spec.Auth
|
|
}
|
|
if method.Auth != nil {
|
|
auth = method.Auth
|
|
}
|
|
|
|
if auth != nil && auth.Required && auth.Capability != "" {
|
|
requiredCapability = auth.Capability
|
|
|
|
// 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("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
|
|
}
|
|
}
|
|
|
|
// Build upstream URL
|
|
upstreamURL, err := url.Parse(adapter.Spec.Upstream.URL)
|
|
if err != nil {
|
|
d.writeError(w, problem.NewProblem(http.StatusInternalServerError, "about:blank#server-error",
|
|
"Internal Server Error", fmt.Sprintf("invalid upstream URL: %v", err)))
|
|
return
|
|
}
|
|
|
|
// Create reverse proxy with director that rewrites the path
|
|
proxy := httputil.NewSingleHostReverseProxy(upstreamURL)
|
|
proxy.Director = func(req *http.Request) {
|
|
req.URL.Scheme = upstreamURL.Scheme
|
|
req.URL.Host = upstreamURL.Host
|
|
req.URL.Path = method.UpstreamPath
|
|
req.RequestURI = ""
|
|
req.Host = upstreamURL.Host
|
|
}
|
|
|
|
// Set timeout
|
|
timeout := adapter.Spec.Upstream.TimeoutSeconds
|
|
if timeout <= 0 {
|
|
timeout = 30
|
|
}
|
|
proxy.Transport = &http.Transport{
|
|
DialContext: (&net.Dialer{Timeout: time.Duration(timeout) * time.Second}).DialContext,
|
|
TLSHandshakeTimeout: time.Duration(timeout) * time.Second,
|
|
}
|
|
|
|
// Forward the request
|
|
proxy.ServeHTTP(w, r)
|
|
}
|
|
|
|
|
|
|
|
func (d *Dispatcher) writeError(w http.ResponseWriter, p *problem.Problem) {
|
|
_ = p.Write(w)
|
|
}
|