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 3 commits into
Conversation
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. |
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>
…ction 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 <frank@tagtoo.com> Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
333f308 to
d0017f2
Compare
d0017f2 to
01f0513
Compare
…tegy
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 <frank@tagtoo.com>
|
Thanks for the detailed review @jhrozek! I've reworked the implementation to address all four points: Changes1. Anonymous mode (was: The strategy now explicitly skips injection when 2. Architecture (was: unconditional injection for every backend) Removed the hardcoded outgoingAuth:
type: claim_injection
claimInjection:
claims: [sub, email] # opt-in; defaults to [sub] only3. Chain order (was: comment described execution order backwards) The strategy runs via 4. PII data minimisation (was: email/name forwarded unconditionally)
claimInjection:
claims: [sub, email]Files changed
All existing tests pass (3173 total). |
jhrozek
left a comment
There was a problem hiding this comment.
Thanks for the rework — the claim_injection strategy is the right shape and it cleanly addresses all four of my earlier points: anonymous is skipped, it's per-backend opt-in, email/name need explicit opt-in, and the chain-order confusion is gone. That part I'm happy with.
But I think the PR currently ships two injection paths, and the second one undoes most of that. Alongside the strategy, it adds pkg/transport/middleware/claim_injection.go wired unconditionally into HTTPTransport.Start() ("Always inject user identity claims"). That transport is the transparent proxy every thv run workload uses — the factory builds it for SSE + streamable-http, and the sole caller is pkg/runner/runner.go:403 — so this runs for all HTTP MCP servers, not just vmcp backends. It injects sub+email+name with no opt-in and only guards identity != nil, so it doesn't skip anonymous: anonymous.go returns a non-nil identity and LocalUserMiddleware returns the OS username, so backends end up with X-User-Email: anonymous@localhost or the local user's name. That's the same anonymous + PII behavior we removed from the strategy, just at a broader layer.
The PR description says the hardcoded path was "removed entirely" and moved to an opt-in strategy — but the diff adds this always-on path, so the two don't quite match. I think the fix is to drop the transport middleware and keep the strategy. If there's a real need to forward identity for non-vmcp thv run servers, that's worth doing as its own opt-in change rather than always-on here.
One more thing the strategy doc raises but the code doesn't enforce: it tells backends to trust X-User-* without their own validation, but neither injector strips inbound X-User-* first. On an unauthenticated path a client can just send X-User-Sub: someone-else and it's forwarded as-is. Whatever sets these headers should Del them before Set, so a spoofed value can't ride through.
Smaller stuff inline. Not blocking on the nits, but the transport middleware and the spoofing gap I'd want sorted before this goes in.
| @@ -0,0 +1,30 @@ | |||
| # Build toolhive vmcp with our ClaimInjectionMiddleware patch | |||
There was a problem hiding this comment.
This looks like a leftover POC file — it references ~/github/toolhive, a poc/toolhive-poc/ path that isn't in the repo, "our ClaimInjectionMiddleware patch", and pins golang:1.26-alpine. Can we drop it from the PR?
| // 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(), | ||
| }) |
There was a problem hiding this comment.
This wires claim injection in unconditionally for every HTTP-transport workload, not just vmcp backends. The comment "When no identity is in context (anonymous request), this middleware is a no-op" isn't accurate either: anonymous mode and the no-OIDC local path both put a non-nil identity in context, so the headers do get sent (X-User-Sub: anonymous, or the OS username). I'd remove this middleware and rely on the claim_injection strategy, which is opt-in and already handles the anonymous/health-check cases.
| 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) | ||
| } |
There was a problem hiding this comment.
Two things if this survives in any form: it injects email/name with no opt-in (the strategy defaults to sub-only for PII reasons), and it doesn't Del the X-User-* headers before setting, so a client-supplied value passes straight through on identity-less requests. Also no unit tests for this file, unlike the strategy.
| | `tokenExchange` _[auth.types.TokenExchangeConfig](#authtypestokenexchangeconfig)_ | TokenExchange contains configuration for token exchange auth strategy.<br />Used when Type = "token_exchange". | | | | ||
| | `upstreamInject` _[auth.types.UpstreamInjectConfig](#authtypesupstreaminjectconfig)_ | UpstreamInject contains configuration for upstream inject auth strategy.<br />Used when Type = "upstream_inject". | | | | ||
| | `awsSts` _[auth.types.AwsStsConfig](#authtypesawsstsconfig)_ | AwsSts contains configuration for AWS STS auth strategy.<br />Used when Type = "aws_sts". | | | | ||
| | `claimInjection` _[auth.types.ClaimInjectionConfig](#authtypesclaiminjectionconfig)_ | ClaimInjection contains configuration for the claim injection auth strategy.<br />Used when Type = "claim_injection". | | | |
There was a problem hiding this comment.
While here — the type enum a few lines up (line 100) still lists the old set without claim_injection, so it looks like task docs wasn't re-run after the types.go change. Mind regenerating?
| require.NotNil(t, base.received) | ||
| assert.NotSame(t, orig, base.received, "fallback injection should clone the request") | ||
| } | ||
|
|
There was a problem hiding this comment.
Stray trailing-blank-line hunk — unrelated to the change, worth dropping to keep the diff scoped.
|
And sorry for the late reply, I was on vacation last week! |
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.