Skip to content

Commit 1122157

Browse files
committed
feat(vmcp): inject user identity as HTTP headers into backend requests
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 <frank@tagtoo.com>
1 parent 2043cf6 commit 1122157

2 files changed

Lines changed: 120 additions & 1 deletion

File tree

pkg/vmcp/session/internal/backend/mcp_session.go

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,32 @@ func (i *identityRoundTripper) RoundTrip(req *http.Request) (*http.Response, err
9595
return i.base.RoundTrip(req)
9696
}
9797

98+
// claimInjectionRoundTripper injects authenticated user identity claims as HTTP headers
99+
// so backend MCP servers can identify the user without OAuth token introspection.
100+
//
101+
// Headers injected when identity is present:
102+
// - X-User-Sub: the authenticated user's subject claim (Google/OIDC sub)
103+
// - X-User-Email: the user's email address (if present in token)
104+
// - X-User-Name: the user's display name (if present in token)
105+
type claimInjectionRoundTripper struct {
106+
base http.RoundTripper
107+
identity *auth.Identity
108+
}
109+
110+
func (c *claimInjectionRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
111+
cloned := req.Clone(req.Context())
112+
if c.identity.Subject != "" {
113+
cloned.Header.Set("X-User-Sub", c.identity.Subject)
114+
}
115+
if c.identity.Email != "" {
116+
cloned.Header.Set("X-User-Email", c.identity.Email)
117+
}
118+
if c.identity.Name != "" {
119+
cloned.Header.Set("X-User-Name", c.identity.Name)
120+
}
121+
return c.base.RoundTrip(cloned)
122+
}
123+
98124
// Compile-time assertion: mcpSession must implement Session.
99125
var _ Session = (*mcpSession)(nil)
100126

@@ -296,7 +322,7 @@ func createMCPClient(
296322
slog.Debug("Applied authentication strategy", "strategy", strategy.Name(), "backendID", target.WorkloadID)
297323

298324
// Build shared transport chain (innermost first → outermost):
299-
// http.DefaultTransport → authRoundTripper → identityRoundTripper → headerForwardRoundTripper
325+
// http.DefaultTransport → authRoundTripper → identityRoundTripper → claimInjectionRoundTripper → headerForwardRoundTripper
300326
// On an outbound request, the outermost stage runs first: header-forward
301327
// injects its headers onto a request that does not yet carry auth/identity
302328
// headers, then inner stages run and call Set() unconditionally so any
@@ -318,6 +344,11 @@ func createMCPClient(
318344
// refreshed identity placed on the request context by
319345
// auth.TokenValidator.Middleware (see issue #5323).
320346
base = &identityRoundTripper{base: base, fallbackIdentity: identity}
347+
// Inject user identity as HTTP headers so backend MCP servers can read
348+
// X-User-Sub / X-User-Email without needing their own /introspect calls.
349+
if identity != nil {
350+
base = &claimInjectionRoundTripper{base: base, identity: identity}
351+
}
321352
base, err = headerforward.BuildHeaderForwardTripper(ctx, base, target.HeaderForward, provider, target.WorkloadID)
322353
if err != nil {
323354
return nil, fmt.Errorf("failed to build header-forward transport for backend %s: %w", target.WorkloadID, err)

pkg/vmcp/session/internal/backend/roundtripper_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,3 +328,91 @@ func TestIdentityRoundTripper_FallbackIdentity_InjectionClonesRequest(t *testing
328328
require.NotNil(t, base.received)
329329
assert.NotSame(t, orig, base.received, "fallback injection should clone the request")
330330
}
331+
332+
// ---------------------------------------------------------------------------
333+
// claimInjectionRoundTripper
334+
// ---------------------------------------------------------------------------
335+
336+
func TestClaimInjectionRoundTripper_AllFields_InjectsHeaders(t *testing.T) {
337+
t.Parallel()
338+
339+
identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{
340+
Subject: "108352771234567890",
341+
Email: "user@example.com",
342+
Name: "Test User",
343+
}}
344+
base := &okTransport{}
345+
rt := &claimInjectionRoundTripper{base: base, identity: identity}
346+
347+
orig := newTestRequest(context.Background(), t)
348+
resp, err := rt.RoundTrip(orig)
349+
350+
require.NoError(t, err)
351+
assert.Equal(t, http.StatusOK, resp.StatusCode)
352+
353+
require.NotNil(t, base.received)
354+
assert.Equal(t, "108352771234567890", base.received.Header.Get("X-User-Sub"))
355+
assert.Equal(t, "user@example.com", base.received.Header.Get("X-User-Email"))
356+
assert.Equal(t, "Test User", base.received.Header.Get("X-User-Name"))
357+
}
358+
359+
func TestClaimInjectionRoundTripper_EmptyEmail_DoesNotInjectEmailHeader(t *testing.T) {
360+
t.Parallel()
361+
362+
identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{
363+
Subject: "sub-only",
364+
// Email and Name intentionally omitted.
365+
}}
366+
base := &okTransport{}
367+
rt := &claimInjectionRoundTripper{base: base, identity: identity}
368+
369+
orig := newTestRequest(context.Background(), t)
370+
_, err := rt.RoundTrip(orig)
371+
require.NoError(t, err)
372+
373+
require.NotNil(t, base.received)
374+
assert.Equal(t, "sub-only", base.received.Header.Get("X-User-Sub"), "X-User-Sub must be set")
375+
assert.Empty(t, base.received.Header.Get("X-User-Email"), "X-User-Email must not be set when empty")
376+
assert.Empty(t, base.received.Header.Get("X-User-Name"), "X-User-Name must not be set when empty")
377+
}
378+
379+
func TestClaimInjectionRoundTripper_EmptySubject_DoesNotInjectSubHeader(t *testing.T) {
380+
t.Parallel()
381+
382+
identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{
383+
// Subject intentionally omitted.
384+
Email: "user@example.com",
385+
}}
386+
base := &okTransport{}
387+
rt := &claimInjectionRoundTripper{base: base, identity: identity}
388+
389+
orig := newTestRequest(context.Background(), t)
390+
_, err := rt.RoundTrip(orig)
391+
require.NoError(t, err)
392+
393+
require.NotNil(t, base.received)
394+
assert.Empty(t, base.received.Header.Get("X-User-Sub"), "X-User-Sub must not be set when subject is empty")
395+
assert.Equal(t, "user@example.com", base.received.Header.Get("X-User-Email"))
396+
}
397+
398+
func TestClaimInjectionRoundTripper_ClonesRequest_OriginalUnmodified(t *testing.T) {
399+
t.Parallel()
400+
401+
identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{
402+
Subject: "clone-test",
403+
Email: "clone@example.com",
404+
}}
405+
base := &okTransport{}
406+
rt := &claimInjectionRoundTripper{base: base, identity: identity}
407+
408+
orig := newTestRequest(context.Background(), t)
409+
_, err := rt.RoundTrip(orig)
410+
require.NoError(t, err)
411+
412+
// The forwarded request must be a distinct clone, not the original.
413+
require.NotNil(t, base.received)
414+
assert.NotSame(t, orig, base.received, "claimInjectionRoundTripper must clone the request")
415+
416+
// The original request must not be mutated.
417+
assert.Empty(t, orig.Header.Get("X-User-Sub"), "original request header must not be mutated")
418+
}

0 commit comments

Comments
 (0)