diff --git a/internal/identity/headers.go b/internal/identity/headers.go new file mode 100644 index 0000000..3930832 --- /dev/null +++ b/internal/identity/headers.go @@ -0,0 +1,111 @@ +// Package identity extracts authenticated user identity from JWT claims +// and injects forwarding headers into proxied requests. +// +// Headers injected after JWT validation: +// +// X-Forwarded-User: subject (sub claim) +// X-Forwarded-Roles: comma-separated roles or permissions +// X-Acting-Service: authorized party (azp claim), only for service accounts +// X-Auth-Verified: "true" when gateway validated the JWT +// +// Security contract: downstream services MUST only accept traffic from the +// gateway (enforced by NetworkPolicy). They trust these headers because the +// gateway is the sole ingress path. +package identity + +import ( + "net/http" + "strings" + + "github.com/golang-jwt/jwt/v5" +) + +// Headers that the gateway controls. Incoming values from clients are +// stripped to prevent spoofing. +const ( + HeaderUser = "X-Forwarded-User" + HeaderRoles = "X-Forwarded-Roles" + HeaderActingService = "X-Acting-Service" + HeaderAuthVerified = "X-Auth-Verified" +) + +// managed lists all headers this package owns. Used for stripping and cleanup. +var managed = []string{ + HeaderUser, + HeaderRoles, + HeaderActingService, + HeaderAuthVerified, +} + +// StripIncoming removes all gateway-managed identity headers from an +// inbound request, preventing clients from spoofing identity. +// Call this early in the handler chain, before any routing. +func StripIncoming(r *http.Request) { + for _, h := range managed { + r.Header.Del(h) + } +} + +// Inject extracts identity from validated JWT claims and sets the +// corresponding forwarding headers on the request. Only call this +// after successful JWT validation. +func Inject(r *http.Request, claims jwt.MapClaims) { + r.Header.Set(HeaderAuthVerified, "true") + + if sub := claimString(claims, "sub"); sub != "" { + r.Header.Set(HeaderUser, sub) + } + + if roles := claimStringSlice(claims, "roles"); len(roles) > 0 { + r.Header.Set(HeaderRoles, strings.Join(roles, ",")) + } else if perms := claimStringSlice(claims, "permissions"); len(perms) > 0 { + r.Header.Set(HeaderRoles, strings.Join(perms, ",")) + } + + if azp := claimString(claims, "azp"); azp != "" { + sub := claimString(claims, "sub") + // Only set acting-service when azp differs from sub + // (i.e., a service account acting, not the user themselves) + if azp != sub { + r.Header.Set(HeaderActingService, azp) + } + } +} + +// claimString extracts a string value from claims, returning "" if +// the key is missing or not a string. +func claimString(claims jwt.MapClaims, key string) string { + val, ok := claims[key] + if !ok || val == nil { + return "" + } + s, ok := val.(string) + if !ok { + return "" + } + return s +} + +// claimStringSlice extracts a []string from claims. JWT libraries +// deserialize JSON arrays as []interface{}, so each element is +// type-asserted individually. Non-string elements are skipped. +func claimStringSlice(claims jwt.MapClaims, key string) []string { + val, ok := claims[key] + if !ok || val == nil { + return nil + } + raw, ok := val.([]interface{}) + if !ok { + return nil + } + out := make([]string, 0, len(raw)) + for _, v := range raw { + if s, ok := v.(string); ok && s != "" { + out = append(out, s) + } + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/internal/identity/headers_test.go b/internal/identity/headers_test.go new file mode 100644 index 0000000..292a3c6 --- /dev/null +++ b/internal/identity/headers_test.go @@ -0,0 +1,205 @@ +package identity + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/golang-jwt/jwt/v5" +) + +func TestStripIncoming_RemovesSpoofedHeaders(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set(HeaderUser, "evil-spoof") + r.Header.Set(HeaderRoles, "admin:*") + r.Header.Set(HeaderActingService, "fake-service") + r.Header.Set(HeaderAuthVerified, "true") + + StripIncoming(r) + + for _, h := range managed { + if got := r.Header.Get(h); got != "" { + t.Errorf("header %s should be stripped, got %q", h, got) + } + } +} + +func TestStripIncoming_PreservesOtherHeaders(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("Authorization", "Bearer token") + r.Header.Set("Content-Type", "application/json") + r.Header.Set(HeaderUser, "spoof") + + StripIncoming(r) + + if got := r.Header.Get("Authorization"); got != "Bearer token" { + t.Errorf("Authorization should be preserved, got %q", got) + } + if got := r.Header.Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type should be preserved, got %q", got) + } +} + +func TestInject_ServiceAccount(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + claims := jwt.MapClaims{ + "sub": "abc123-hashed-id", + "azp": "portfolio-agent", + "roles": []interface{}{"llm:inference", "memory:read"}, + } + + Inject(r, claims) + + assertHeader(t, r, HeaderAuthVerified, "true") + assertHeader(t, r, HeaderUser, "abc123-hashed-id") + assertHeader(t, r, HeaderRoles, "llm:inference,memory:read") + assertHeader(t, r, HeaderActingService, "portfolio-agent") +} + +func TestInject_HumanUser(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + claims := jwt.MapClaims{ + "sub": "user-hash-456", + "azp": "api-gw", + "permissions": []interface{}{"*"}, + } + + Inject(r, claims) + + assertHeader(t, r, HeaderAuthVerified, "true") + assertHeader(t, r, HeaderUser, "user-hash-456") + assertHeader(t, r, HeaderRoles, "*") + // azp != sub, so acting-service is set + assertHeader(t, r, HeaderActingService, "api-gw") +} + +func TestInject_SameSubAndAzp_NoActingService(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + claims := jwt.MapClaims{ + "sub": "portfolio-agent", + "azp": "portfolio-agent", + "roles": []interface{}{"llm:inference"}, + } + + Inject(r, claims) + + assertHeader(t, r, HeaderActingService, "") +} + +func TestInject_RolesOverPermissions(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + claims := jwt.MapClaims{ + "sub": "user-1", + "roles": []interface{}{"llm:inference"}, + "permissions": []interface{}{"admin:*"}, + } + + Inject(r, claims) + + // roles takes precedence over permissions + assertHeader(t, r, HeaderRoles, "llm:inference") +} + +func TestInject_PermissionsFallback(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + claims := jwt.MapClaims{ + "sub": "user-1", + "permissions": []interface{}{"grafana:read", "grafana:write"}, + } + + Inject(r, claims) + + assertHeader(t, r, HeaderRoles, "grafana:read,grafana:write") +} + +func TestInject_EmptyClaims(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + claims := jwt.MapClaims{} + + Inject(r, claims) + + assertHeader(t, r, HeaderAuthVerified, "true") + assertHeader(t, r, HeaderUser, "") + assertHeader(t, r, HeaderRoles, "") + assertHeader(t, r, HeaderActingService, "") +} + +func TestInject_NilValuesInClaims(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + claims := jwt.MapClaims{ + "sub": nil, + "azp": nil, + "roles": nil, + } + + Inject(r, claims) + + assertHeader(t, r, HeaderAuthVerified, "true") + assertHeader(t, r, HeaderUser, "") + assertHeader(t, r, HeaderRoles, "") +} + +func TestInject_WildcardPermission(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + claims := jwt.MapClaims{ + "sub": "admin-user", + "permissions": []interface{}{"*"}, + } + + Inject(r, claims) + + // Wildcard passed as literal, never expanded + assertHeader(t, r, HeaderRoles, "*") +} + +func TestInject_MixedTypeRolesArray(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + claims := jwt.MapClaims{ + "sub": "user-1", + "roles": []interface{}{"llm:inference", 42, nil, "", "memory:read"}, + } + + Inject(r, claims) + + // Non-string and empty elements skipped + assertHeader(t, r, HeaderRoles, "llm:inference,memory:read") +} + +func TestInject_EmptyRolesArray(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + claims := jwt.MapClaims{ + "sub": "user-1", + "roles": []interface{}{}, + "permissions": []interface{}{"backup:read"}, + } + + Inject(r, claims) + + // Empty roles falls through to permissions + assertHeader(t, r, HeaderRoles, "backup:read") +} + +func TestStripThenInject_OverwritesSpoof(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set(HeaderUser, "evil-spoof") + r.Header.Set(HeaderAuthVerified, "true") + + StripIncoming(r) + + claims := jwt.MapClaims{ + "sub": "real-user", + "roles": []interface{}{"llm:inference"}, + } + Inject(r, claims) + + assertHeader(t, r, HeaderUser, "real-user") + assertHeader(t, r, HeaderAuthVerified, "true") +} + +func assertHeader(t *testing.T, r *http.Request, key, want string) { + t.Helper() + got := r.Header.Get(key) + if got != want { + t.Errorf("header %s = %q, want %q", key, got, want) + } +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 0ef4df3..b21125e 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -15,6 +15,7 @@ import ( "forgejo.riotpiao.com/rock/homelab-frontend/internal/auth" "forgejo.riotpiao.com/rock/homelab-frontend/internal/config" + "forgejo.riotpiao.com/rock/homelab-frontend/internal/identity" "forgejo.riotpiao.com/rock/homelab-frontend/internal/logging" "forgejo.riotpiao.com/rock/homelab-frontend/internal/tracing" ) @@ -303,6 +304,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + // Strip spoofed identity headers from all inbound requests. + // Must happen before any routing — even unauthenticated paths. + identity.StripIncoming(r) + // JWT Authentication for /v1/* endpoints if h.jwtValidator != nil && strings.HasPrefix(r.URL.Path, "/v1/") { authHeader := r.Header.Get("Authorization") @@ -331,6 +336,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + // Inject identity headers for downstream services + identity.Inject(r, claims) + // Check required capability if configured if h.config.Auth.RequiredCapability != "" { if !h.jwtValidator.CheckPermissions(claims, h.config.Auth.RequiredCapability, "*") {