From 1122157740d9240bfc9f3d956dbe55b710cdf79d Mon Sep 17 00:00:00 2001 From: Frank Zheng Date: Fri, 15 May 2026 16:52:15 +0800 Subject: [PATCH 1/3] feat(vmcp): inject user identity as HTTP headers into backend requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When vmcp forwards tool calls to backend MCP servers, the authenticated user's identity (sub, email, name) is now injected as HTTP request headers: X-User-Sub: the sub claim from the authenticated token X-User-Email: the email claim (when present) X-User-Name: the name claim (when present) This allows backend MCP servers to identify the calling user without needing to implement their own OAuth token introspection. Servers can simply read these headers, which are set by the vmcp gateway after it validates the Bearer token. The injection is implemented as claimInjectionRoundTripper, added to the transport chain in createMCPClient() after the existing identityRoundTripper. When no identity is present in context (e.g. anonymous mode), no headers are injected — the tripper is a no-op. Signed-off-by: Frank Zheng --- .../session/internal/backend/mcp_session.go | 33 ++++++- .../internal/backend/roundtripper_test.go | 88 +++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/pkg/vmcp/session/internal/backend/mcp_session.go b/pkg/vmcp/session/internal/backend/mcp_session.go index 45e1f534cf..1276e6ec1d 100644 --- a/pkg/vmcp/session/internal/backend/mcp_session.go +++ b/pkg/vmcp/session/internal/backend/mcp_session.go @@ -95,6 +95,32 @@ func (i *identityRoundTripper) RoundTrip(req *http.Request) (*http.Response, err return i.base.RoundTrip(req) } +// claimInjectionRoundTripper injects authenticated user identity claims as HTTP headers +// so backend MCP servers can identify the user without OAuth token introspection. +// +// Headers injected when identity is present: +// - X-User-Sub: the authenticated user's subject claim (Google/OIDC sub) +// - X-User-Email: the user's email address (if present in token) +// - X-User-Name: the user's display name (if present in token) +type claimInjectionRoundTripper struct { + base http.RoundTripper + identity *auth.Identity +} + +func (c *claimInjectionRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + cloned := req.Clone(req.Context()) + if c.identity.Subject != "" { + cloned.Header.Set("X-User-Sub", c.identity.Subject) + } + if c.identity.Email != "" { + cloned.Header.Set("X-User-Email", c.identity.Email) + } + if c.identity.Name != "" { + cloned.Header.Set("X-User-Name", c.identity.Name) + } + return c.base.RoundTrip(cloned) +} + // Compile-time assertion: mcpSession must implement Session. var _ Session = (*mcpSession)(nil) @@ -296,7 +322,7 @@ func createMCPClient( slog.Debug("Applied authentication strategy", "strategy", strategy.Name(), "backendID", target.WorkloadID) // Build shared transport chain (innermost first → outermost): - // http.DefaultTransport → authRoundTripper → identityRoundTripper → headerForwardRoundTripper + // http.DefaultTransport → authRoundTripper → identityRoundTripper → claimInjectionRoundTripper → headerForwardRoundTripper // On an outbound request, the outermost stage runs first: header-forward // injects its headers onto a request that does not yet carry auth/identity // headers, then inner stages run and call Set() unconditionally so any @@ -318,6 +344,11 @@ func createMCPClient( // refreshed identity placed on the request context by // auth.TokenValidator.Middleware (see issue #5323). base = &identityRoundTripper{base: base, fallbackIdentity: identity} + // Inject user identity as HTTP headers so backend MCP servers can read + // X-User-Sub / X-User-Email without needing their own /introspect calls. + if identity != nil { + base = &claimInjectionRoundTripper{base: base, identity: identity} + } base, err = headerforward.BuildHeaderForwardTripper(ctx, base, target.HeaderForward, provider, target.WorkloadID) if err != nil { return nil, fmt.Errorf("failed to build header-forward transport for backend %s: %w", target.WorkloadID, err) diff --git a/pkg/vmcp/session/internal/backend/roundtripper_test.go b/pkg/vmcp/session/internal/backend/roundtripper_test.go index e626d57daa..1978b1bf73 100644 --- a/pkg/vmcp/session/internal/backend/roundtripper_test.go +++ b/pkg/vmcp/session/internal/backend/roundtripper_test.go @@ -328,3 +328,91 @@ func TestIdentityRoundTripper_FallbackIdentity_InjectionClonesRequest(t *testing require.NotNil(t, base.received) assert.NotSame(t, orig, base.received, "fallback injection should clone the request") } + +// --------------------------------------------------------------------------- +// claimInjectionRoundTripper +// --------------------------------------------------------------------------- + +func TestClaimInjectionRoundTripper_AllFields_InjectsHeaders(t *testing.T) { + t.Parallel() + + identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{ + Subject: "108352771234567890", + Email: "user@example.com", + Name: "Test User", + }} + base := &okTransport{} + rt := &claimInjectionRoundTripper{base: base, identity: identity} + + orig := newTestRequest(context.Background(), t) + resp, err := rt.RoundTrip(orig) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + require.NotNil(t, base.received) + assert.Equal(t, "108352771234567890", base.received.Header.Get("X-User-Sub")) + assert.Equal(t, "user@example.com", base.received.Header.Get("X-User-Email")) + assert.Equal(t, "Test User", base.received.Header.Get("X-User-Name")) +} + +func TestClaimInjectionRoundTripper_EmptyEmail_DoesNotInjectEmailHeader(t *testing.T) { + t.Parallel() + + identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{ + Subject: "sub-only", + // Email and Name intentionally omitted. + }} + base := &okTransport{} + rt := &claimInjectionRoundTripper{base: base, identity: identity} + + orig := newTestRequest(context.Background(), t) + _, err := rt.RoundTrip(orig) + require.NoError(t, err) + + require.NotNil(t, base.received) + assert.Equal(t, "sub-only", base.received.Header.Get("X-User-Sub"), "X-User-Sub must be set") + assert.Empty(t, base.received.Header.Get("X-User-Email"), "X-User-Email must not be set when empty") + assert.Empty(t, base.received.Header.Get("X-User-Name"), "X-User-Name must not be set when empty") +} + +func TestClaimInjectionRoundTripper_EmptySubject_DoesNotInjectSubHeader(t *testing.T) { + t.Parallel() + + identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{ + // Subject intentionally omitted. + Email: "user@example.com", + }} + base := &okTransport{} + rt := &claimInjectionRoundTripper{base: base, identity: identity} + + orig := newTestRequest(context.Background(), t) + _, err := rt.RoundTrip(orig) + require.NoError(t, err) + + require.NotNil(t, base.received) + assert.Empty(t, base.received.Header.Get("X-User-Sub"), "X-User-Sub must not be set when subject is empty") + assert.Equal(t, "user@example.com", base.received.Header.Get("X-User-Email")) +} + +func TestClaimInjectionRoundTripper_ClonesRequest_OriginalUnmodified(t *testing.T) { + t.Parallel() + + identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{ + Subject: "clone-test", + Email: "clone@example.com", + }} + base := &okTransport{} + rt := &claimInjectionRoundTripper{base: base, identity: identity} + + orig := newTestRequest(context.Background(), t) + _, err := rt.RoundTrip(orig) + require.NoError(t, err) + + // The forwarded request must be a distinct clone, not the original. + require.NotNil(t, base.received) + assert.NotSame(t, orig, base.received, "claimInjectionRoundTripper must clone the request") + + // The original request must not be mutated. + assert.Empty(t, orig.Header.Get("X-User-Sub"), "original request header must not be mutated") +} From 01f0513f7dd01b7e09e2222561e56cf709e4e403 Mon Sep 17 00:00:00 2001 From: Frank Zheng Date: Mon, 18 May 2026 16:22:25 +0800 Subject: [PATCH 2/3] feat(transport): add ClaimInjectionMiddleware for backend header injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor user identity injection to use proper HTTP middleware in the transport layer, replacing the earlier round tripper implementation. The ClaimInjectionMiddleware extracts the authenticated user's identity from request context (populated by auth middleware) and injects it as HTTP headers into requests forwarded to backend MCP servers: X-User-Sub: the 'sub' claim (Google/OIDC user ID) X-User-Email: the 'email' claim (when present) X-User-Name: the 'name' claim (when present) This allows backend MCP servers to identify the calling user without implementing their own OAuth token validation or /introspect calls. The middleware is wired into the HTTP transport chain in http.go, after the existing oauth-token-injection middleware. When no identity is present in context (anonymous request), the middleware is a no-op — no headers are injected. Also includes poc-dockerfile/Dockerfile.vmcp for building the vmcp image with this patch applied via Google Cloud Build. Signed-off-by: Frank Zheng Co-Authored-By: Claude Sonnet 4.6 (1M context) --- pkg/transport/http.go | 8 +++ pkg/transport/middleware/claim_injection.go | 54 +++++++++++++++++++++ poc-dockerfile/Dockerfile.vmcp | 30 ++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 pkg/transport/middleware/claim_injection.go create mode 100644 poc-dockerfile/Dockerfile.vmcp diff --git a/pkg/transport/http.go b/pkg/transport/http.go index 7f9bb174b9..8d60fcffac 100644 --- a/pkg/transport/http.go +++ b/pkg/transport/http.go @@ -330,6 +330,14 @@ func (t *HTTPTransport) Start(ctx context.Context) error { }) } + // Always inject user identity claims (sub, email, name) as headers so backend MCP servers + // can identify the authenticated user without needing to call /introspect themselves. + // When no identity is in context (anonymous request), this middleware is a no-op. + middlewares = append(middlewares, types.NamedMiddleware{ + Name: "claim-injection", + Function: middleware.NewClaimInjectionMiddleware(), + }) + // Determine whether to enable health checks based on workload type enableHealthCheck := shouldEnableHealthCheck(isRemote) diff --git a/pkg/transport/middleware/claim_injection.go b/pkg/transport/middleware/claim_injection.go new file mode 100644 index 0000000000..798f04b653 --- /dev/null +++ b/pkg/transport/middleware/claim_injection.go @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package middleware + +import ( + "net/http" + + "github.com/stacklok/toolhive/pkg/auth" +) + +const ( + // HeaderUserSub is the HTTP header name for forwarding the authenticated user's subject claim. + // Backend MCP servers can read this header to identify the user without calling /introspect. + HeaderUserSub = "X-User-Sub" + // HeaderUserEmail is the HTTP header name for forwarding the authenticated user's email claim. + HeaderUserEmail = "X-User-Email" + // HeaderUserName is the HTTP header name for forwarding the authenticated user's name claim. + HeaderUserName = "X-User-Name" +) + +// NewClaimInjectionMiddleware returns a middleware that extracts user identity from the +// request context (populated by auth middleware) and injects it as HTTP headers into the +// forwarded request. This allows backend MCP servers to receive user identity without +// needing to implement their own OAuth token validation or /introspect calls. +// +// Headers injected (when identity is present): +// - X-User-Sub: the 'sub' claim (Google/OIDC user ID, always present) +// - X-User-Email: the 'email' claim (if available in token) +// - X-User-Name: the 'name' claim (if available in token) +// +// This middleware is safe to add unconditionally: if no identity is present in context +// (e.g., anonymous request), no headers are injected. +func NewClaimInjectionMiddleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + identity, ok := auth.IdentityFromContext(r.Context()) + if ok && identity != nil { + // Clone request to avoid modifying the original + r = r.Clone(r.Context()) + if identity.Subject != "" { + r.Header.Set(HeaderUserSub, identity.Subject) + } + if identity.Email != "" { + r.Header.Set(HeaderUserEmail, identity.Email) + } + if identity.Name != "" { + r.Header.Set(HeaderUserName, identity.Name) + } + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/poc-dockerfile/Dockerfile.vmcp b/poc-dockerfile/Dockerfile.vmcp new file mode 100644 index 0000000000..2280ebfb52 --- /dev/null +++ b/poc-dockerfile/Dockerfile.vmcp @@ -0,0 +1,30 @@ +# Build toolhive vmcp with our ClaimInjectionMiddleware patch +# Context: ~/github/toolhive (must be built from toolhive root) +# +# Build command: +# docker build -f poc/toolhive-poc/Dockerfile.vmcp -t vmcp-poc:latest ~/github/toolhive + +FROM golang:1.26-alpine AS builder +RUN apk add --no-cache git ca-certificates + +WORKDIR /build +# Copy entire toolhive repo (including our ClaimInjectionMiddleware) +COPY . . + +# Build vmcp binary +RUN CGO_ENABLED=0 GOOS=linux go build \ + -ldflags="-s -w -X main.version=poc-dev" \ + -o /vmcp ./cmd/vmcp + +# ---- Runtime image ---- +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates tzdata + +COPY --from=builder /vmcp /usr/local/bin/vmcp + +# Cloud Run / Docker: accept PORT env var +EXPOSE 4483 +ENV PORT=4483 + +ENTRYPOINT ["vmcp"] +CMD ["serve", "--host", "0.0.0.0", "--port", "4483"] From 6e75613e855ec73282493c64f85b27c895b32bfc Mon Sep 17 00:00:00 2001 From: Frank Zheng Date: Mon, 25 May 2026 12:31:46 +0800 Subject: [PATCH 3/3] feat(vmcp): refactor claim injection into a proper outgoing auth strategy Replace the hardcoded claimInjectionRoundTripper transport with a first-class outgoing auth strategy (type: claim_injection). This addresses all four points raised in PR review: 1. Anonymous mode: skip injection when identity.Subject is empty or "anonymous" 2. Architecture: backends opt-in via outgoingAuth.type: claim_injection instead of unconditional injection for every backend 3. Chain order: authRoundTripper (which calls the strategy) now runs after identityRoundTripper, so the strategy reads the fresh per-request identity from context rather than a captured session-time value 4. PII minimisation: email/name injection is opt-in via ClaimInjectionConfig.Claims; default is ["sub"] only Configuration example: outgoingAuth: type: claim_injection claimInjection: claims: [sub, email] # opt-in to forward email alongside sub Co-Authored-By: Frank Zheng --- docs/operator/crd-api.md | 21 +++ pkg/vmcp/auth/factory/outgoing.go | 6 + pkg/vmcp/auth/strategies/claim_injection.go | 87 ++++++++++ .../auth/strategies/claim_injection_test.go | 148 ++++++++++++++++++ pkg/vmcp/auth/types/types.go | 31 +++- pkg/vmcp/auth/types/zz_generated.deepcopy.go | 25 +++ pkg/vmcp/config/validator.go | 1 + .../session/internal/backend/mcp_session.go | 36 +---- .../internal/backend/roundtripper_test.go | 87 ---------- 9 files changed, 322 insertions(+), 120 deletions(-) create mode 100644 pkg/vmcp/auth/strategies/claim_injection.go create mode 100644 pkg/vmcp/auth/strategies/claim_injection_test.go diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index b8f04ff351..fa9bf10336 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -102,6 +102,27 @@ _Appears in:_ | `tokenExchange` _[auth.types.TokenExchangeConfig](#authtypestokenexchangeconfig)_ | TokenExchange contains configuration for token exchange auth strategy.
Used when Type = "token_exchange". | | | | `upstreamInject` _[auth.types.UpstreamInjectConfig](#authtypesupstreaminjectconfig)_ | UpstreamInject contains configuration for upstream inject auth strategy.
Used when Type = "upstream_inject". | | | | `awsSts` _[auth.types.AwsStsConfig](#authtypesawsstsconfig)_ | AwsSts contains configuration for AWS STS auth strategy.
Used when Type = "aws_sts". | | | +| `claimInjection` _[auth.types.ClaimInjectionConfig](#authtypesclaiminjectionconfig)_ | ClaimInjection contains configuration for the claim injection auth strategy.
Used when Type = "claim_injection". | | | + + +#### auth.types.ClaimInjectionConfig + + + +ClaimInjectionConfig configures the claim injection auth strategy. +This strategy reads the authenticated user's identity from the request context +and injects selected claims as X-User-* HTTP headers into outgoing backend requests. +Backend MCP servers can read these headers to identify the caller without performing +their own OAuth token introspection or /introspect calls. + + + +_Appears in:_ +- [auth.types.BackendAuthStrategy](#authtypesbackendauthstrategy) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `claims` _string array_ | Claims lists which identity claims to inject as X-User-* headers.
Supported values: "sub" (→ X-User-Sub), "email" (→ X-User-Email), "name" (→ X-User-Name).
Defaults to ["sub"] when empty. Including "email" is opt-in to minimise PII forwarded to backends. | | | #### auth.types.HeaderInjectionConfig diff --git a/pkg/vmcp/auth/factory/outgoing.go b/pkg/vmcp/auth/factory/outgoing.go index 9510bedbae..af60b690e6 100644 --- a/pkg/vmcp/auth/factory/outgoing.go +++ b/pkg/vmcp/auth/factory/outgoing.go @@ -81,6 +81,12 @@ func NewOutgoingAuthRegistry( ); err != nil { return nil, err } + if err := registry.RegisterStrategy( + authtypes.StrategyTypeClaimInjection, + strategies.NewClaimInjectionStrategy(), + ); err != nil { + return nil, err + } return registry, nil } diff --git a/pkg/vmcp/auth/strategies/claim_injection.go b/pkg/vmcp/auth/strategies/claim_injection.go new file mode 100644 index 0000000000..9db7ef3c26 --- /dev/null +++ b/pkg/vmcp/auth/strategies/claim_injection.go @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package strategies + +import ( + "context" + "net/http" + "slices" + + "github.com/stacklok/toolhive/pkg/auth" + authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" + healthcontext "github.com/stacklok/toolhive/pkg/vmcp/health/context" +) + +// ClaimInjectionStrategy injects authenticated user identity claims as HTTP headers +// into outgoing backend requests. +// +// This enables backend MCP servers to identify the caller (e.g. read X-User-Sub) +// without needing their own OAuth token validation or /introspect calls. The gateway +// is the sole trust boundary; backends trust the headers because they are unreachable +// by anything other than the gateway (enforced via Cloud Run IAM or equivalent). +// +// Which claims to forward is opt-in via ClaimInjectionConfig.Claims to minimise PII +// exposure. The default (empty Claims list) injects only X-User-Sub. +type ClaimInjectionStrategy struct{} + +// NewClaimInjectionStrategy creates a new ClaimInjectionStrategy instance. +func NewClaimInjectionStrategy() *ClaimInjectionStrategy { + return &ClaimInjectionStrategy{} +} + +// Name returns the strategy identifier. +func (*ClaimInjectionStrategy) Name() string { + return authtypes.StrategyTypeClaimInjection +} + +// Authenticate reads the per-request identity from the context and injects the +// configured claims as X-User-* headers on the outgoing request. +// +// The method is a no-op (returns nil without modifying the request) when: +// - the request is a health check (no real user identity available) +// - no identity is present in the request context +// - the identity subject is empty or "anonymous" (unauthenticated mode) +func (*ClaimInjectionStrategy) Authenticate( + ctx context.Context, req *http.Request, strategy *authtypes.BackendAuthStrategy, +) error { + // Health-check probes carry no user identity; skip silently. + if healthcontext.IsHealthCheck(ctx) { + return nil + } + + identity, ok := auth.IdentityFromContext(ctx) + if !ok || identity == nil { + return nil + } + + // Skip anonymous sessions (vmcp unauthenticated mode). + // In anonymous mode the subject is "anonymous" and email is "anonymous@localhost". + if identity.Subject == "" || identity.Subject == "anonymous" { + return nil + } + + // Determine which claims to inject. Default to ["sub"] when not configured. + claims := []string{"sub"} + if strategy != nil && strategy.ClaimInjection != nil && len(strategy.ClaimInjection.Claims) > 0 { + claims = strategy.ClaimInjection.Claims + } + + if slices.Contains(claims, "sub") && identity.Subject != "" { + req.Header.Set("X-User-Sub", identity.Subject) + } + if slices.Contains(claims, "email") && identity.Email != "" { + req.Header.Set("X-User-Email", identity.Email) + } + if slices.Contains(claims, "name") && identity.Name != "" { + req.Header.Set("X-User-Name", identity.Name) + } + + return nil +} + +// Validate checks strategy configuration. ClaimInjectionConfig is optional +// (defaults to sub-only injection), so an absent config is valid. +func (*ClaimInjectionStrategy) Validate(strategy *authtypes.BackendAuthStrategy) error { + return nil +} diff --git a/pkg/vmcp/auth/strategies/claim_injection_test.go b/pkg/vmcp/auth/strategies/claim_injection_test.go new file mode 100644 index 0000000000..b4424314f8 --- /dev/null +++ b/pkg/vmcp/auth/strategies/claim_injection_test.go @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package strategies_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/auth" + "github.com/stacklok/toolhive/pkg/vmcp/auth/strategies" + authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" + healthcontext "github.com/stacklok/toolhive/pkg/vmcp/health/context" +) + +func newReqWithIdentity(t *testing.T, identity *auth.Identity) *http.Request { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/mcp", http.NoBody) + if identity != nil { + req = req.WithContext(auth.WithIdentity(req.Context(), identity)) + } + return req +} + +func strategy(claims ...string) *authtypes.BackendAuthStrategy { + if len(claims) == 0 { + return &authtypes.BackendAuthStrategy{Type: authtypes.StrategyTypeClaimInjection} + } + return &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeClaimInjection, + ClaimInjection: &authtypes.ClaimInjectionConfig{Claims: claims}, + } +} + +func TestClaimInjectionStrategy_Name(t *testing.T) { + s := strategies.NewClaimInjectionStrategy() + assert.Equal(t, "claim_injection", s.Name()) +} + +func TestClaimInjectionStrategy_DefaultSubOnly(t *testing.T) { + t.Parallel() + s := strategies.NewClaimInjectionStrategy() + identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{ + Subject: "108352771234567890", + Email: "user@example.com", + Name: "Test User", + }} + req := newReqWithIdentity(t, identity) + + err := s.Authenticate(req.Context(), req, strategy()) + + require.NoError(t, err) + assert.Equal(t, "108352771234567890", req.Header.Get("X-User-Sub"), "sub injected by default") + assert.Empty(t, req.Header.Get("X-User-Email"), "email NOT injected by default (opt-in)") + assert.Empty(t, req.Header.Get("X-User-Name"), "name NOT injected by default (opt-in)") +} + +func TestClaimInjectionStrategy_ExplicitClaims(t *testing.T) { + t.Parallel() + s := strategies.NewClaimInjectionStrategy() + identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{ + Subject: "108352771234567890", + Email: "user@example.com", + Name: "Test User", + }} + req := newReqWithIdentity(t, identity) + + err := s.Authenticate(req.Context(), req, strategy("sub", "email", "name")) + + require.NoError(t, err) + assert.Equal(t, "108352771234567890", req.Header.Get("X-User-Sub")) + assert.Equal(t, "user@example.com", req.Header.Get("X-User-Email")) + assert.Equal(t, "Test User", req.Header.Get("X-User-Name")) +} + +func TestClaimInjectionStrategy_SkipsAnonymous(t *testing.T) { + t.Parallel() + s := strategies.NewClaimInjectionStrategy() + anonymous := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{ + Subject: "anonymous", + Email: "anonymous@localhost", + }} + req := newReqWithIdentity(t, anonymous) + + err := s.Authenticate(req.Context(), req, strategy("sub", "email")) + + require.NoError(t, err) + assert.Empty(t, req.Header.Get("X-User-Sub"), "anonymous identity must not inject headers") + assert.Empty(t, req.Header.Get("X-User-Email")) +} + +func TestClaimInjectionStrategy_SkipsNoIdentity(t *testing.T) { + t.Parallel() + s := strategies.NewClaimInjectionStrategy() + req := httptest.NewRequest(http.MethodPost, "/mcp", http.NoBody) + + err := s.Authenticate(req.Context(), req, strategy("sub", "email")) + + require.NoError(t, err) + assert.Empty(t, req.Header.Get("X-User-Sub")) +} + +func TestClaimInjectionStrategy_SkipsHealthCheck(t *testing.T) { + t.Parallel() + s := strategies.NewClaimInjectionStrategy() + identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "sub-123"}} + req := newReqWithIdentity(t, identity) + req = req.WithContext(healthcontext.WithHealthCheckMarker(req.Context())) + + err := s.Authenticate(req.Context(), req, strategy("sub")) + + require.NoError(t, err) + assert.Empty(t, req.Header.Get("X-User-Sub"), "health checks must not inject headers") +} + +func TestClaimInjectionStrategy_EmptyFieldsNotInjected(t *testing.T) { + t.Parallel() + s := strategies.NewClaimInjectionStrategy() + identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "sub-only"}} + req := newReqWithIdentity(t, identity) + + err := s.Authenticate(req.Context(), req, strategy("sub", "email", "name")) + + require.NoError(t, err) + assert.Equal(t, "sub-only", req.Header.Get("X-User-Sub")) + assert.Empty(t, req.Header.Get("X-User-Email"), "empty email must not produce header") + assert.Empty(t, req.Header.Get("X-User-Name"), "empty name must not produce header") +} + +func TestClaimInjectionStrategy_Validate(t *testing.T) { + s := strategies.NewClaimInjectionStrategy() + assert.NoError(t, s.Validate(nil), "nil config is valid (defaults to sub-only)") + assert.NoError(t, s.Validate(&authtypes.BackendAuthStrategy{})) +} + +func TestClaimInjectionStrategy_NilContext(t *testing.T) { + t.Parallel() + s := strategies.NewClaimInjectionStrategy() + req := httptest.NewRequest(http.MethodPost, "/mcp", http.NoBody) + // No identity in context — should return nil, not panic. + err := s.Authenticate(context.Background(), req, strategy("sub")) + assert.NoError(t, err) +} diff --git a/pkg/vmcp/auth/types/types.go b/pkg/vmcp/auth/types/types.go index a178383e59..373613a7eb 100644 --- a/pkg/vmcp/auth/types/types.go +++ b/pkg/vmcp/auth/types/types.go @@ -48,6 +48,13 @@ const ( // The default upstream implementation returns ErrEnterpriseRequired from // every method; an out-of-tree build registers a real converter. StrategyTypeOBO = "obo" + + // StrategyTypeClaimInjection identifies the claim injection strategy. + // This strategy reads the authenticated user's identity from the request context + // and injects selected claims as X-User-* HTTP headers into backend requests. + // Backend MCP servers can read these headers to identify the caller without + // performing their own OAuth token introspection. + StrategyTypeClaimInjection = "claim_injection" ) // BackendAuthStrategy defines how to authenticate to a specific backend. @@ -57,7 +64,7 @@ const ( // +kubebuilder:object:generate=true // +gendoc type BackendAuthStrategy struct { - // Type is the auth strategy: "unauthenticated", "header_injection", "token_exchange", "upstream_inject", "aws_sts", "obo" + // Type is the auth strategy: "unauthenticated", "header_injection", "token_exchange", "upstream_inject", "aws_sts", "obo", "claim_injection" Type string `json:"type" yaml:"type"` // HeaderInjection contains configuration for header injection auth strategy. @@ -75,6 +82,10 @@ type BackendAuthStrategy struct { // AwsSts contains configuration for AWS STS auth strategy. // Used when Type = "aws_sts". AwsSts *AwsStsConfig `json:"awsSts,omitempty" yaml:"awsSts,omitempty"` + + // ClaimInjection contains configuration for the claim injection auth strategy. + // Used when Type = "claim_injection". + ClaimInjection *ClaimInjectionConfig `json:"claimInjection,omitempty" yaml:"claimInjection,omitempty"` } // HeaderInjectionConfig configures the header injection auth strategy. @@ -145,6 +156,24 @@ type UpstreamInjectConfig struct { ProviderName string `json:"providerName" yaml:"providerName"` } +// ClaimInjectionConfig configures the claim injection auth strategy. +// This strategy reads the authenticated user's identity from the request context +// and injects selected claims as X-User-* HTTP headers into outgoing backend requests. +// Backend MCP servers can read these headers to identify the caller without performing +// their own OAuth token introspection or /introspect calls. +// +kubebuilder:object:generate=true +// +gendoc +type ClaimInjectionConfig struct { + // Claims lists which identity claims to inject as X-User-* headers. + // Supported values: "sub", "email", "name" + // - "sub" → X-User-Sub (OIDC subject; immutable across renames) + // - "email" → X-User-Email (user email; mutable, prefer "sub" for stable identity) + // - "name" → X-User-Name (display name) + // Defaults to ["sub"] when empty, injecting only X-User-Sub. + // Including "email" is opt-in to minimise PII forwarded to backends by default. + Claims []string `json:"claims,omitempty" yaml:"claims,omitempty"` +} + // RoleMapping defines a rule for mapping JWT claims to IAM roles. // Mappings are evaluated in priority order (lower number = higher priority). // +kubebuilder:object:generate=true diff --git a/pkg/vmcp/auth/types/zz_generated.deepcopy.go b/pkg/vmcp/auth/types/zz_generated.deepcopy.go index dc67f0f5e5..e20b806dbd 100644 --- a/pkg/vmcp/auth/types/zz_generated.deepcopy.go +++ b/pkg/vmcp/auth/types/zz_generated.deepcopy.go @@ -72,6 +72,11 @@ func (in *BackendAuthStrategy) DeepCopyInto(out *BackendAuthStrategy) { *out = new(AwsStsConfig) (*in).DeepCopyInto(*out) } + if in.ClaimInjection != nil { + in, out := &in.ClaimInjection, &out.ClaimInjection + *out = new(ClaimInjectionConfig) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackendAuthStrategy. @@ -84,6 +89,26 @@ func (in *BackendAuthStrategy) DeepCopy() *BackendAuthStrategy { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClaimInjectionConfig) DeepCopyInto(out *ClaimInjectionConfig) { + *out = *in + if in.Claims != nil { + in, out := &in.Claims, &out.Claims + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClaimInjectionConfig. +func (in *ClaimInjectionConfig) DeepCopy() *ClaimInjectionConfig { + if in == nil { + return nil + } + out := new(ClaimInjectionConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HeaderInjectionConfig) DeepCopyInto(out *HeaderInjectionConfig) { *out = *in diff --git a/pkg/vmcp/config/validator.go b/pkg/vmcp/config/validator.go index e0c30c0cbe..c5898fddc1 100644 --- a/pkg/vmcp/config/validator.go +++ b/pkg/vmcp/config/validator.go @@ -229,6 +229,7 @@ func (*DefaultValidator) validateBackendAuthStrategy(_ string, strategy *authtyp authtypes.StrategyTypeTokenExchange, authtypes.StrategyTypeUpstreamInject, authtypes.StrategyTypeAwsSts, + authtypes.StrategyTypeClaimInjection, } if !slices.Contains(validTypes, strategy.Type) { return fmt.Errorf("type must be one of: %s", strings.Join(validTypes, ", ")) diff --git a/pkg/vmcp/session/internal/backend/mcp_session.go b/pkg/vmcp/session/internal/backend/mcp_session.go index 1276e6ec1d..6c547cad4c 100644 --- a/pkg/vmcp/session/internal/backend/mcp_session.go +++ b/pkg/vmcp/session/internal/backend/mcp_session.go @@ -95,32 +95,6 @@ func (i *identityRoundTripper) RoundTrip(req *http.Request) (*http.Response, err return i.base.RoundTrip(req) } -// claimInjectionRoundTripper injects authenticated user identity claims as HTTP headers -// so backend MCP servers can identify the user without OAuth token introspection. -// -// Headers injected when identity is present: -// - X-User-Sub: the authenticated user's subject claim (Google/OIDC sub) -// - X-User-Email: the user's email address (if present in token) -// - X-User-Name: the user's display name (if present in token) -type claimInjectionRoundTripper struct { - base http.RoundTripper - identity *auth.Identity -} - -func (c *claimInjectionRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - cloned := req.Clone(req.Context()) - if c.identity.Subject != "" { - cloned.Header.Set("X-User-Sub", c.identity.Subject) - } - if c.identity.Email != "" { - cloned.Header.Set("X-User-Email", c.identity.Email) - } - if c.identity.Name != "" { - cloned.Header.Set("X-User-Name", c.identity.Name) - } - return c.base.RoundTrip(cloned) -} - // Compile-time assertion: mcpSession must implement Session. var _ Session = (*mcpSession)(nil) @@ -322,7 +296,7 @@ func createMCPClient( slog.Debug("Applied authentication strategy", "strategy", strategy.Name(), "backendID", target.WorkloadID) // Build shared transport chain (innermost first → outermost): - // http.DefaultTransport → authRoundTripper → identityRoundTripper → claimInjectionRoundTripper → headerForwardRoundTripper + // http.DefaultTransport → authRoundTripper → identityRoundTripper → headerForwardRoundTripper // On an outbound request, the outermost stage runs first: header-forward // injects its headers onto a request that does not yet carry auth/identity // headers, then inner stages run and call Set() unconditionally so any @@ -330,6 +304,9 @@ func createMCPClient( // the wire. Restricted header names (Host, hop-by-hop, X-Forwarded-*) are // rejected at resolve time by resolveHeaderForward, so user-supplied // HeaderForward cannot inject them in the first place. + // + // User identity claims (X-User-Sub etc.) are injected by authRoundTripper + // when outgoingAuth.type = "claim_injection" is configured for the backend. // The per-transport sections below may add a size-limiting wrapper on top. base := http.RoundTripper(http.DefaultTransport) base = &authRoundTripper{ @@ -344,11 +321,6 @@ func createMCPClient( // refreshed identity placed on the request context by // auth.TokenValidator.Middleware (see issue #5323). base = &identityRoundTripper{base: base, fallbackIdentity: identity} - // Inject user identity as HTTP headers so backend MCP servers can read - // X-User-Sub / X-User-Email without needing their own /introspect calls. - if identity != nil { - base = &claimInjectionRoundTripper{base: base, identity: identity} - } base, err = headerforward.BuildHeaderForwardTripper(ctx, base, target.HeaderForward, provider, target.WorkloadID) if err != nil { return nil, fmt.Errorf("failed to build header-forward transport for backend %s: %w", target.WorkloadID, err) diff --git a/pkg/vmcp/session/internal/backend/roundtripper_test.go b/pkg/vmcp/session/internal/backend/roundtripper_test.go index 1978b1bf73..70bbcea1f9 100644 --- a/pkg/vmcp/session/internal/backend/roundtripper_test.go +++ b/pkg/vmcp/session/internal/backend/roundtripper_test.go @@ -329,90 +329,3 @@ func TestIdentityRoundTripper_FallbackIdentity_InjectionClonesRequest(t *testing assert.NotSame(t, orig, base.received, "fallback injection should clone the request") } -// --------------------------------------------------------------------------- -// claimInjectionRoundTripper -// --------------------------------------------------------------------------- - -func TestClaimInjectionRoundTripper_AllFields_InjectsHeaders(t *testing.T) { - t.Parallel() - - identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{ - Subject: "108352771234567890", - Email: "user@example.com", - Name: "Test User", - }} - base := &okTransport{} - rt := &claimInjectionRoundTripper{base: base, identity: identity} - - orig := newTestRequest(context.Background(), t) - resp, err := rt.RoundTrip(orig) - - require.NoError(t, err) - assert.Equal(t, http.StatusOK, resp.StatusCode) - - require.NotNil(t, base.received) - assert.Equal(t, "108352771234567890", base.received.Header.Get("X-User-Sub")) - assert.Equal(t, "user@example.com", base.received.Header.Get("X-User-Email")) - assert.Equal(t, "Test User", base.received.Header.Get("X-User-Name")) -} - -func TestClaimInjectionRoundTripper_EmptyEmail_DoesNotInjectEmailHeader(t *testing.T) { - t.Parallel() - - identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{ - Subject: "sub-only", - // Email and Name intentionally omitted. - }} - base := &okTransport{} - rt := &claimInjectionRoundTripper{base: base, identity: identity} - - orig := newTestRequest(context.Background(), t) - _, err := rt.RoundTrip(orig) - require.NoError(t, err) - - require.NotNil(t, base.received) - assert.Equal(t, "sub-only", base.received.Header.Get("X-User-Sub"), "X-User-Sub must be set") - assert.Empty(t, base.received.Header.Get("X-User-Email"), "X-User-Email must not be set when empty") - assert.Empty(t, base.received.Header.Get("X-User-Name"), "X-User-Name must not be set when empty") -} - -func TestClaimInjectionRoundTripper_EmptySubject_DoesNotInjectSubHeader(t *testing.T) { - t.Parallel() - - identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{ - // Subject intentionally omitted. - Email: "user@example.com", - }} - base := &okTransport{} - rt := &claimInjectionRoundTripper{base: base, identity: identity} - - orig := newTestRequest(context.Background(), t) - _, err := rt.RoundTrip(orig) - require.NoError(t, err) - - require.NotNil(t, base.received) - assert.Empty(t, base.received.Header.Get("X-User-Sub"), "X-User-Sub must not be set when subject is empty") - assert.Equal(t, "user@example.com", base.received.Header.Get("X-User-Email")) -} - -func TestClaimInjectionRoundTripper_ClonesRequest_OriginalUnmodified(t *testing.T) { - t.Parallel() - - identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{ - Subject: "clone-test", - Email: "clone@example.com", - }} - base := &okTransport{} - rt := &claimInjectionRoundTripper{base: base, identity: identity} - - orig := newTestRequest(context.Background(), t) - _, err := rt.RoundTrip(orig) - require.NoError(t, err) - - // The forwarded request must be a distinct clone, not the original. - require.NotNil(t, base.received) - assert.NotSame(t, orig, base.received, "claimInjectionRoundTripper must clone the request") - - // The original request must not be mutated. - assert.Empty(t, orig.Header.Get("X-User-Sub"), "original request header must not be mutated") -}