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") +}