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
|
||||
}
|
||||
Reference in New Issue
Block a user