feat(vmcp): inject user identity as HTTP headers into backend requests#5291
feat(vmcp): inject user identity as HTTP headers into backend requests#5291fkztw wants to merge 1 commit into
Conversation
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>
0f894e9 to
333f308
Compare
jhrozek
left a comment
There was a problem hiding this comment.
A few things on the new claim-injection roundtripper — none of these are necessarily blockers on their own, but the anonymous-mode and chain-order ones should at least get addressed before this lands.
| 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 { |
There was a problem hiding this comment.
The commit message says "When no identity is present in context (e.g. anonymous mode), no headers are injected" — but anonymous identity isn't nil. pkg/auth/anonymous.go builds a real *Identity with Subject="anonymous", Email="anonymous@localhost", Name="Anonymous User". So this if identity != nil guard passes in anonymous mode and the backend ends up with X-User-Sub: anonymous, etc.
Worth fixing one way or the other — either gate explicitly here (e.g. add an IsAnonymous() helper on *Identity and check !identity.IsAnonymous()), or update the commit message. The combination of implicit network trust + anonymous user looking like a real principal at the backend is the bit that worries me.
| // 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} |
There was a problem hiding this comment.
vmcp already has a per-backend outgoing-auth strategy registry — see the constants in pkg/vmcp/auth/types/types.go (unauthenticated, header_injection, token_exchange, upstream_inject, aws_sts) and the strategy resolved a few lines up at strategy, err := registry.GetStrategy(strategyName). This change is a parallel header-mutation path that runs unconditionally for every backend regardless of which strategy is selected.
Think about a setup with github-tools + atlassian-tools via upstream_inject and an internal-api via header_injection. With this code, the GitHub and Atlassian backends also get X-User-Sub / X-User-Email / X-User-Name, even though they're authenticating with a real upstream token and don't need (or really want) those headers — the headers describe the vmcp user, not the upstream service principal.
Could this be a new strategy variant instead? Either fold a fromClaim: source into header_injection, or add a separate claim_injection strategy. That way backends opt in and the claim → header mapping is configurable per backend.
| 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. |
There was a problem hiding this comment.
Heads up — this comment is the inverse of the actual execution order. http.RoundTripper chains wrap inward, so the outermost wrapper runs first on the outgoing request. As wired below, an outgoing request goes claimInjectionRoundTripper → identityRoundTripper → authRoundTripper → http.DefaultTransport. So the real order is "claim injection → identity propagation → auth", not the other way round.
Either flip the order in the comment, or add a sentence clarifying that wrap order is the reverse of execution order.
| if c.identity.Subject != "" { | ||
| cloned.Header.Set("X-User-Sub", c.identity.Subject) | ||
| } | ||
| if c.identity.Email != "" { |
There was a problem hiding this comment.
Email (and Name a few lines down) are PII. As written, this sends them to every backend unconditionally whenever an identity is present. A backend that only needs sub for authorization still gets the user's email — which then very likely ends up in that backend's request logs.
If the feature stays in this form, defaulting to sub only and requiring explicit opt-in for email/name would be much better data-minimization. If it moves to a per-backend strategy (see the other comment), the claim set should be configurable there anyway.
|
Thanks jhrozek — this is exactly the kind of architectural feedback I was hoping for. Agreed on all four points: Comment 1 (anonymous mode, L300): Bug confirmed — Comment 3 (chain order comment, L288): Will fix — the comment is backwards. Outermost wrapper runs first on the outgoing request, so the actual execution order is Comments 2 + 4 (unconditional injection + PII, L301/L91): Both point to the same root cause — unconditional injection to all backends is the wrong default. I'd like to address both by moving claim injection from an implicit behavior wired in outgoingAuth:
type: claim_injection
claimInjection:
claims: [sub] # default: sub only
# claims: [sub, email] # explicit opt-in for PII fieldsThis way:
Does that direction sound right to you? Happy to revise the PR accordingly. |
Summary
When
vmcpforwards tool calls to backend MCP servers, the authenticated user's identity is now injected as HTTP request headers:X-User-Sub: thesubclaim from the authenticated token (set when a subject is present)X-User-Email: theemailclaim (set only when non-empty)X-User-Name: thenameclaim (set only when non-empty)Motivation
When vmcp acts as an aggregating gateway, it validates the user's Bearer token via the configured incoming authentication strategy (OIDC, anonymous, or an embedded auth server). Backend MCP servers receive the forwarded request but currently have no information about which user initiated the call — only that vmcp accepted the request.
This makes it difficult for backends to:
Injecting identity claims as request headers is a common pattern in API gateway architectures — see nginx
auth_requestpropagation, Envoyext_authzresponse headers, Google IAPX-Goog-Authenticated-User-Email, and AWS API Gateway request-context identity fields.Implementation
A new
claimInjectionRoundTripperis added to the per-backend transport chain increateMCPClient(), placed after the existingidentityRoundTripper:The tripper reads the
*auth.Identityalready attached at client-creation time and sets headers for non-empty claim values. When no identity is configured (e.g. anonymous mode without a populated identity), it is a no-op and the original request is forwarded unchanged.The forwarded request is cloned before mutation; the caller-supplied request and its headers are not modified.
Type of change
Test plan
task test)task lint-fix)Four new tests cover
claimInjectionRoundTripperinroundtripper_test.go, following the same patterns as the existingidentityRoundTrippertests:X-User-SubManual verification with a backend stub confirmed the expected headers reach the downstream service.
API Compatibility
v1beta1API.Changes
pkg/vmcp/session/internal/backend/mcp_session.goclaimInjectionRoundTripperand wire it intocreateMCPClient()transport chainpkg/vmcp/session/internal/backend/roundtripper_test.goclaimInjectionRoundTripperDoes this introduce a user-facing change?
Yes. Backend MCP servers connected via vmcp will now receive
X-User-Sub,X-User-Email, andX-User-NameHTTP headers containing the authenticated user's identity claims. Servers that do not read these headers are unaffected.