From 333f3089bc6a90fb5d9d9a7cc1f8c64ade018e92 Mon Sep 17 00:00:00 2001 From: Frank Zheng Date: Fri, 15 May 2026 16:52:15 +0800 Subject: [PATCH] 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 4ad386ae39..ce83814af6 100644 --- a/pkg/vmcp/session/internal/backend/mcp_session.go +++ b/pkg/vmcp/session/internal/backend/mcp_session.go @@ -71,6 +71,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) @@ -259,7 +285,7 @@ func createMCPClient( slog.Debug("Applied authentication strategy", "strategy", strategy.Name(), "backendID", target.WorkloadID) - // Build shared transport chain: auth → identity propagation. + // Build shared transport chain: auth → identity propagation → claim injection. // The per-transport sections below may add a size-limiting wrapper on top. base := http.RoundTripper(http.DefaultTransport) base = &authRoundTripper{ @@ -269,6 +295,11 @@ func createMCPClient( target: target, } base = &identityRoundTripper{base: base, identity: 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} + } var c *mcpclient.Client switch target.TransportType { diff --git a/pkg/vmcp/session/internal/backend/roundtripper_test.go b/pkg/vmcp/session/internal/backend/roundtripper_test.go index 40c1135e85..b87dc14033 100644 --- a/pkg/vmcp/session/internal/backend/roundtripper_test.go +++ b/pkg/vmcp/session/internal/backend/roundtripper_test.go @@ -246,3 +246,91 @@ func TestIdentityRoundTripper_WithIdentity_ClonesRequest(t *testing.T) { require.NotNil(t, base.received) assert.NotSame(t, orig, base.received, "non-nil identity 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") +}