Files
homelab-frontend/internal/serviceadapter/router.go
T
poimenandrock 09318778fa
CI / CI (push) Successful in 3m43s
feat(serviceadapter): enforce JWT auth on X-Service dispatch (#16)
SQS dispatcher hardcoded a JWT validator pointing at authentik.riotpiao.com/application/o/sqs/jwks/ — provider doesn't exist. Every SQS request got 403 regardless of token.

Co-authored-by: poimen <[email protected]>
2026-09-08 23:20:31 +00:00

225 lines
6.6 KiB
Go

package serviceadapter
import (
"context"
"fmt"
"net"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/identity"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/problem"
)
// Dispatcher routes X-Service requests to upstreams.
type Dispatcher struct {
registry *Registry
jwtValidator *auth.Validator
}
// NewDispatcher creates a dispatcher with a shared multi-issuer JWT validator.
// Pass nil to disable auth enforcement (all requests pass through).
func NewDispatcher(registry *Registry, jwtValidator *auth.Validator) *Dispatcher {
return &Dispatcher{
registry: registry,
jwtValidator: jwtValidator,
}
}
// Matches returns true if the request has an 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.
func (d *Dispatcher) Dispatch(w http.ResponseWriter, r *http.Request) {
serviceName := r.Header.Get("X-Service")
if serviceName == "" {
d.writeError(w, problem.BadRequest("X-Service header required"))
return
}
adapter := d.registry.Get(serviceName)
if adapter == nil {
d.writeError(w, problem.NotFound(fmt.Sprintf("service '%s' not found", serviceName)))
return
}
resourceName := r.Header.Get("X-Resource")
if resourceName == "" {
d.writeError(w, problem.BadRequest("X-Resource header required"))
return
}
resource := findResource(adapter, resourceName)
if resource == nil {
d.writeError(w, problem.NotFound(
fmt.Sprintf("resource '%s' not found in service '%s'", resourceName, serviceName)))
return
}
method := findMethod(resource, r.Method)
if method == nil {
d.writeError(w, problem.NotFound(
fmt.Sprintf("method %s not defined for resource '%s'", r.Method, resourceName)))
return
}
// JWT auth enforcement for adapters that require it
if adapter.Spec.Auth.Required && d.jwtValidator != nil {
if !d.authenticate(w, r, serviceName, method.Verb) {
return
}
}
upstreamURL := adapter.Spec.Upstream.URL
if strings.HasPrefix(upstreamURL, "grpc://") {
d.dispatchGRPC(w, r, upstreamURL, method, adapter)
} else {
d.dispatchHTTP(w, r, upstreamURL, method, adapter)
}
}
// authenticate validates the JWT and checks service-level capability.
// Returns false (and writes error response) if auth fails.
func (d *Dispatcher) authenticate(w http.ResponseWriter, r *http.Request, serviceName, verb string) bool {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
d.writeError(w, problem.NewProblem(http.StatusUnauthorized,
"about:blank#unauthorized", "Unauthorized",
fmt.Sprintf("service '%s' requires Authorization header", serviceName)))
return false
}
claims, err := d.jwtValidator.ValidateBearerToken(authHeader)
if err != nil {
d.writeError(w, problem.NewProblem(http.StatusForbidden,
"about:blank#forbidden", "Forbidden",
fmt.Sprintf("JWT validation failed: %v", err)))
return false
}
// Check capability: <service>:read for GET/HEAD, <service>:write for mutating verbs
required := capabilityForVerb(serviceName, verb)
if !d.jwtValidator.CheckPermissions(claims, required, "*") {
d.writeError(w, problem.NewProblem(http.StatusForbidden,
"about:blank#insufficient-permissions", "Insufficient Permissions",
fmt.Sprintf("required capability: %s", required)))
return false
}
// Inject identity headers for downstream
identity.Inject(r, claims)
return true
}
// capabilityForVerb maps HTTP verbs to <service>:read or <service>:write.
func capabilityForVerb(serviceName, verb string) string {
switch verb {
case "GET", "HEAD", "OPTIONS":
return serviceName + ":read"
default:
return serviceName + ":write"
}
}
func findResource(adapter *ServiceAdapter, name string) *Resource {
for i := range adapter.Spec.Resources {
if adapter.Spec.Resources[i].Name == name {
return &adapter.Spec.Resources[i]
}
}
return nil
}
func findMethod(resource *Resource, verb string) *Method {
for i := range resource.Methods {
if resource.Methods[i].Verb == verb {
return &resource.Methods[i]
}
}
return nil
}
func (d *Dispatcher) dispatchHTTP(w http.ResponseWriter, r *http.Request, upstreamURL string, method *Method, adapter *ServiceAdapter) {
parsedURL, err := url.Parse(upstreamURL)
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
}
proxy := httputil.NewSingleHostReverseProxy(parsedURL)
proxy.Director = func(req *http.Request) {
req.URL.Scheme = parsedURL.Scheme
req.URL.Host = parsedURL.Host
req.URL.Path = method.UpstreamPath
req.RequestURI = ""
req.Host = parsedURL.Host
}
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,
}
proxy.ServeHTTP(w, r)
}
func (d *Dispatcher) dispatchGRPC(w http.ResponseWriter, r *http.Request, upstreamURL string, method *Method, adapter *ServiceAdapter) {
host := strings.TrimPrefix(upstreamURL, "grpc://")
if host == upstreamURL {
d.writeError(w, problem.NewProblem(http.StatusInternalServerError,
"about:blank#server-error", "Internal Server Error",
"invalid gRPC URL format"))
return
}
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/grpc") {
d.writeError(w, problem.NewProblem(http.StatusBadRequest,
"about:blank#bad-request", "Bad Request",
"gRPC service requires application/grpc content-type"))
return
}
timeout := adapter.Spec.Upstream.TimeoutSeconds
if timeout <= 0 {
timeout = 30
}
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(timeout)*time.Second)
defer cancel()
conn, err := grpc.DialContext(ctx, host,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(100*1024*1024)),
)
if err != nil {
d.writeError(w, problem.NewProblem(http.StatusBadGateway,
"about:blank#bad-gateway", "Bad Gateway",
fmt.Sprintf("failed to dial gRPC upstream: %v", err)))
return
}
defer conn.Close()
d.writeError(w, problem.NewProblem(http.StatusNotImplemented,
"about:blank#not-implemented", "Not Implemented",
"gRPC forwarding not yet implemented"))
}
func (d *Dispatcher) writeError(w http.ResponseWriter, p *problem.Problem) {
_ = p.Write(w)
}