// 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 }