feat(identity): inject X-Forwarded-User headers after JWT validation (#15)
CI / CI (push) Successful in 3m14s
CI / CI (push) Successful in 3m14s
Closes homelab#9 (P3.4) ## Changes - New `internal/identity` package: header injection + anti-spoofing - `proxy.go`: strip spoofed headers on all requests, inject identity after JWT validation ## Headers | Header | Source | When | |--------|--------|------| | X-Forwarded-User | sub claim | Always after JWT | | X-Forwarded-Roles | roles or permissions claim | Always after JWT | | X-Acting-Service | azp claim | Only when azp != sub | | X-Auth-Verified | literal "true" | Always after JWT | ## Tests 13 tests, 93.9% coverage. Covers: spoofing, service accounts, human users, empty claims, nil values, wildcard, mixed types, precedence. --------- Co-authored-by: Poimen <[email protected]> Reviewed-on: #15
This commit was merged in pull request #15.
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
"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/logging"
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/tracing"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/tracing"
|
||||||
)
|
)
|
||||||
@@ -303,6 +304,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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
|
// JWT Authentication for /v1/* endpoints
|
||||||
if h.jwtValidator != nil && strings.HasPrefix(r.URL.Path, "/v1/") {
|
if h.jwtValidator != nil && strings.HasPrefix(r.URL.Path, "/v1/") {
|
||||||
authHeader := r.Header.Get("Authorization")
|
authHeader := r.Header.Get("Authorization")
|
||||||
@@ -331,6 +336,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inject identity headers for downstream services
|
||||||
|
identity.Inject(r, claims)
|
||||||
|
|
||||||
// Check required capability if configured
|
// Check required capability if configured
|
||||||
if h.config.Auth.RequiredCapability != "" {
|
if h.config.Auth.RequiredCapability != "" {
|
||||||
if !h.jwtValidator.CheckPermissions(claims, h.config.Auth.RequiredCapability, "*") {
|
if !h.jwtValidator.CheckPermissions(claims, h.config.Auth.RequiredCapability, "*") {
|
||||||
|
|||||||
Reference in New Issue
Block a user