feat(identity): inject X-Forwarded-User headers after JWT validation (#15)
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:
2026-09-08 23:08:39 +00:00
co-authored by poimen
parent 5dd76f3b49
commit 6f7b850193
3 changed files with 324 additions and 0 deletions
+205
View File
@@ -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)
}
}