diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index b8f04ff351..fa9bf10336 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -102,6 +102,27 @@ _Appears in:_ | `tokenExchange` _[auth.types.TokenExchangeConfig](#authtypestokenexchangeconfig)_ | TokenExchange contains configuration for token exchange auth strategy.
Used when Type = "token_exchange". | | | | `upstreamInject` _[auth.types.UpstreamInjectConfig](#authtypesupstreaminjectconfig)_ | UpstreamInject contains configuration for upstream inject auth strategy.
Used when Type = "upstream_inject". | | | | `awsSts` _[auth.types.AwsStsConfig](#authtypesawsstsconfig)_ | AwsSts contains configuration for AWS STS auth strategy.
Used when Type = "aws_sts". | | | +| `claimInjection` _[auth.types.ClaimInjectionConfig](#authtypesclaiminjectionconfig)_ | ClaimInjection contains configuration for the claim injection auth strategy.
Used when Type = "claim_injection". | | | + + +#### auth.types.ClaimInjectionConfig + + + +ClaimInjectionConfig configures the claim injection auth strategy. +This strategy reads the authenticated user's identity from the request context +and injects selected claims as X-User-* HTTP headers into outgoing backend requests. +Backend MCP servers can read these headers to identify the caller without performing +their own OAuth token introspection or /introspect calls. + + + +_Appears in:_ +- [auth.types.BackendAuthStrategy](#authtypesbackendauthstrategy) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `claims` _string array_ | Claims lists which identity claims to inject as X-User-* headers.
Supported values: "sub" (→ X-User-Sub), "email" (→ X-User-Email), "name" (→ X-User-Name).
Defaults to ["sub"] when empty. Including "email" is opt-in to minimise PII forwarded to backends. | | | #### auth.types.HeaderInjectionConfig diff --git a/pkg/transport/http.go b/pkg/transport/http.go index 7f9bb174b9..8d60fcffac 100644 --- a/pkg/transport/http.go +++ b/pkg/transport/http.go @@ -330,6 +330,14 @@ func (t *HTTPTransport) Start(ctx context.Context) error { }) } + // 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(), + }) + // Determine whether to enable health checks based on workload type enableHealthCheck := shouldEnableHealthCheck(isRemote) diff --git a/pkg/transport/middleware/claim_injection.go b/pkg/transport/middleware/claim_injection.go new file mode 100644 index 0000000000..798f04b653 --- /dev/null +++ b/pkg/transport/middleware/claim_injection.go @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package middleware + +import ( + "net/http" + + "github.com/stacklok/toolhive/pkg/auth" +) + +const ( + // HeaderUserSub is the HTTP header name for forwarding the authenticated user's subject claim. + // Backend MCP servers can read this header to identify the user without calling /introspect. + HeaderUserSub = "X-User-Sub" + // HeaderUserEmail is the HTTP header name for forwarding the authenticated user's email claim. + HeaderUserEmail = "X-User-Email" + // HeaderUserName is the HTTP header name for forwarding the authenticated user's name claim. + HeaderUserName = "X-User-Name" +) + +// NewClaimInjectionMiddleware returns a middleware that extracts user identity from the +// request context (populated by auth middleware) and injects it as HTTP headers into the +// forwarded request. This allows backend MCP servers to receive user identity without +// needing to implement their own OAuth token validation or /introspect calls. +// +// Headers injected (when identity is present): +// - X-User-Sub: the 'sub' claim (Google/OIDC user ID, always present) +// - X-User-Email: the 'email' claim (if available in token) +// - X-User-Name: the 'name' claim (if available in token) +// +// This middleware is safe to add unconditionally: if no identity is present in context +// (e.g., anonymous request), no headers are injected. +func NewClaimInjectionMiddleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + identity, ok := auth.IdentityFromContext(r.Context()) + if ok && identity != nil { + // Clone request to avoid modifying the original + r = r.Clone(r.Context()) + 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) + } + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/pkg/vmcp/auth/factory/outgoing.go b/pkg/vmcp/auth/factory/outgoing.go index 9510bedbae..af60b690e6 100644 --- a/pkg/vmcp/auth/factory/outgoing.go +++ b/pkg/vmcp/auth/factory/outgoing.go @@ -81,6 +81,12 @@ func NewOutgoingAuthRegistry( ); err != nil { return nil, err } + if err := registry.RegisterStrategy( + authtypes.StrategyTypeClaimInjection, + strategies.NewClaimInjectionStrategy(), + ); err != nil { + return nil, err + } return registry, nil } diff --git a/pkg/vmcp/auth/strategies/claim_injection.go b/pkg/vmcp/auth/strategies/claim_injection.go new file mode 100644 index 0000000000..9db7ef3c26 --- /dev/null +++ b/pkg/vmcp/auth/strategies/claim_injection.go @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package strategies + +import ( + "context" + "net/http" + "slices" + + "github.com/stacklok/toolhive/pkg/auth" + authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" + healthcontext "github.com/stacklok/toolhive/pkg/vmcp/health/context" +) + +// ClaimInjectionStrategy injects authenticated user identity claims as HTTP headers +// into outgoing backend requests. +// +// This enables backend MCP servers to identify the caller (e.g. read X-User-Sub) +// without needing their own OAuth token validation or /introspect calls. The gateway +// is the sole trust boundary; backends trust the headers because they are unreachable +// by anything other than the gateway (enforced via Cloud Run IAM or equivalent). +// +// Which claims to forward is opt-in via ClaimInjectionConfig.Claims to minimise PII +// exposure. The default (empty Claims list) injects only X-User-Sub. +type ClaimInjectionStrategy struct{} + +// NewClaimInjectionStrategy creates a new ClaimInjectionStrategy instance. +func NewClaimInjectionStrategy() *ClaimInjectionStrategy { + return &ClaimInjectionStrategy{} +} + +// Name returns the strategy identifier. +func (*ClaimInjectionStrategy) Name() string { + return authtypes.StrategyTypeClaimInjection +} + +// Authenticate reads the per-request identity from the context and injects the +// configured claims as X-User-* headers on the outgoing request. +// +// The method is a no-op (returns nil without modifying the request) when: +// - the request is a health check (no real user identity available) +// - no identity is present in the request context +// - the identity subject is empty or "anonymous" (unauthenticated mode) +func (*ClaimInjectionStrategy) Authenticate( + ctx context.Context, req *http.Request, strategy *authtypes.BackendAuthStrategy, +) error { + // Health-check probes carry no user identity; skip silently. + if healthcontext.IsHealthCheck(ctx) { + return nil + } + + identity, ok := auth.IdentityFromContext(ctx) + if !ok || identity == nil { + return nil + } + + // Skip anonymous sessions (vmcp unauthenticated mode). + // In anonymous mode the subject is "anonymous" and email is "anonymous@localhost". + if identity.Subject == "" || identity.Subject == "anonymous" { + return nil + } + + // Determine which claims to inject. Default to ["sub"] when not configured. + claims := []string{"sub"} + if strategy != nil && strategy.ClaimInjection != nil && len(strategy.ClaimInjection.Claims) > 0 { + claims = strategy.ClaimInjection.Claims + } + + if slices.Contains(claims, "sub") && identity.Subject != "" { + req.Header.Set("X-User-Sub", identity.Subject) + } + if slices.Contains(claims, "email") && identity.Email != "" { + req.Header.Set("X-User-Email", identity.Email) + } + if slices.Contains(claims, "name") && identity.Name != "" { + req.Header.Set("X-User-Name", identity.Name) + } + + return nil +} + +// Validate checks strategy configuration. ClaimInjectionConfig is optional +// (defaults to sub-only injection), so an absent config is valid. +func (*ClaimInjectionStrategy) Validate(strategy *authtypes.BackendAuthStrategy) error { + return nil +} diff --git a/pkg/vmcp/auth/strategies/claim_injection_test.go b/pkg/vmcp/auth/strategies/claim_injection_test.go new file mode 100644 index 0000000000..b4424314f8 --- /dev/null +++ b/pkg/vmcp/auth/strategies/claim_injection_test.go @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package strategies_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/auth" + "github.com/stacklok/toolhive/pkg/vmcp/auth/strategies" + authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" + healthcontext "github.com/stacklok/toolhive/pkg/vmcp/health/context" +) + +func newReqWithIdentity(t *testing.T, identity *auth.Identity) *http.Request { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/mcp", http.NoBody) + if identity != nil { + req = req.WithContext(auth.WithIdentity(req.Context(), identity)) + } + return req +} + +func strategy(claims ...string) *authtypes.BackendAuthStrategy { + if len(claims) == 0 { + return &authtypes.BackendAuthStrategy{Type: authtypes.StrategyTypeClaimInjection} + } + return &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeClaimInjection, + ClaimInjection: &authtypes.ClaimInjectionConfig{Claims: claims}, + } +} + +func TestClaimInjectionStrategy_Name(t *testing.T) { + s := strategies.NewClaimInjectionStrategy() + assert.Equal(t, "claim_injection", s.Name()) +} + +func TestClaimInjectionStrategy_DefaultSubOnly(t *testing.T) { + t.Parallel() + s := strategies.NewClaimInjectionStrategy() + identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{ + Subject: "108352771234567890", + Email: "user@example.com", + Name: "Test User", + }} + req := newReqWithIdentity(t, identity) + + err := s.Authenticate(req.Context(), req, strategy()) + + require.NoError(t, err) + assert.Equal(t, "108352771234567890", req.Header.Get("X-User-Sub"), "sub injected by default") + assert.Empty(t, req.Header.Get("X-User-Email"), "email NOT injected by default (opt-in)") + assert.Empty(t, req.Header.Get("X-User-Name"), "name NOT injected by default (opt-in)") +} + +func TestClaimInjectionStrategy_ExplicitClaims(t *testing.T) { + t.Parallel() + s := strategies.NewClaimInjectionStrategy() + identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{ + Subject: "108352771234567890", + Email: "user@example.com", + Name: "Test User", + }} + req := newReqWithIdentity(t, identity) + + err := s.Authenticate(req.Context(), req, strategy("sub", "email", "name")) + + require.NoError(t, err) + assert.Equal(t, "108352771234567890", req.Header.Get("X-User-Sub")) + assert.Equal(t, "user@example.com", req.Header.Get("X-User-Email")) + assert.Equal(t, "Test User", req.Header.Get("X-User-Name")) +} + +func TestClaimInjectionStrategy_SkipsAnonymous(t *testing.T) { + t.Parallel() + s := strategies.NewClaimInjectionStrategy() + anonymous := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{ + Subject: "anonymous", + Email: "anonymous@localhost", + }} + req := newReqWithIdentity(t, anonymous) + + err := s.Authenticate(req.Context(), req, strategy("sub", "email")) + + require.NoError(t, err) + assert.Empty(t, req.Header.Get("X-User-Sub"), "anonymous identity must not inject headers") + assert.Empty(t, req.Header.Get("X-User-Email")) +} + +func TestClaimInjectionStrategy_SkipsNoIdentity(t *testing.T) { + t.Parallel() + s := strategies.NewClaimInjectionStrategy() + req := httptest.NewRequest(http.MethodPost, "/mcp", http.NoBody) + + err := s.Authenticate(req.Context(), req, strategy("sub", "email")) + + require.NoError(t, err) + assert.Empty(t, req.Header.Get("X-User-Sub")) +} + +func TestClaimInjectionStrategy_SkipsHealthCheck(t *testing.T) { + t.Parallel() + s := strategies.NewClaimInjectionStrategy() + identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "sub-123"}} + req := newReqWithIdentity(t, identity) + req = req.WithContext(healthcontext.WithHealthCheckMarker(req.Context())) + + err := s.Authenticate(req.Context(), req, strategy("sub")) + + require.NoError(t, err) + assert.Empty(t, req.Header.Get("X-User-Sub"), "health checks must not inject headers") +} + +func TestClaimInjectionStrategy_EmptyFieldsNotInjected(t *testing.T) { + t.Parallel() + s := strategies.NewClaimInjectionStrategy() + identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "sub-only"}} + req := newReqWithIdentity(t, identity) + + err := s.Authenticate(req.Context(), req, strategy("sub", "email", "name")) + + require.NoError(t, err) + assert.Equal(t, "sub-only", req.Header.Get("X-User-Sub")) + assert.Empty(t, req.Header.Get("X-User-Email"), "empty email must not produce header") + assert.Empty(t, req.Header.Get("X-User-Name"), "empty name must not produce header") +} + +func TestClaimInjectionStrategy_Validate(t *testing.T) { + s := strategies.NewClaimInjectionStrategy() + assert.NoError(t, s.Validate(nil), "nil config is valid (defaults to sub-only)") + assert.NoError(t, s.Validate(&authtypes.BackendAuthStrategy{})) +} + +func TestClaimInjectionStrategy_NilContext(t *testing.T) { + t.Parallel() + s := strategies.NewClaimInjectionStrategy() + req := httptest.NewRequest(http.MethodPost, "/mcp", http.NoBody) + // No identity in context — should return nil, not panic. + err := s.Authenticate(context.Background(), req, strategy("sub")) + assert.NoError(t, err) +} diff --git a/pkg/vmcp/auth/types/types.go b/pkg/vmcp/auth/types/types.go index a178383e59..373613a7eb 100644 --- a/pkg/vmcp/auth/types/types.go +++ b/pkg/vmcp/auth/types/types.go @@ -48,6 +48,13 @@ const ( // The default upstream implementation returns ErrEnterpriseRequired from // every method; an out-of-tree build registers a real converter. StrategyTypeOBO = "obo" + + // StrategyTypeClaimInjection identifies the claim injection strategy. + // This strategy reads the authenticated user's identity from the request context + // and injects selected claims as X-User-* HTTP headers into backend requests. + // Backend MCP servers can read these headers to identify the caller without + // performing their own OAuth token introspection. + StrategyTypeClaimInjection = "claim_injection" ) // BackendAuthStrategy defines how to authenticate to a specific backend. @@ -57,7 +64,7 @@ const ( // +kubebuilder:object:generate=true // +gendoc type BackendAuthStrategy struct { - // Type is the auth strategy: "unauthenticated", "header_injection", "token_exchange", "upstream_inject", "aws_sts", "obo" + // Type is the auth strategy: "unauthenticated", "header_injection", "token_exchange", "upstream_inject", "aws_sts", "obo", "claim_injection" Type string `json:"type" yaml:"type"` // HeaderInjection contains configuration for header injection auth strategy. @@ -75,6 +82,10 @@ type BackendAuthStrategy struct { // AwsSts contains configuration for AWS STS auth strategy. // Used when Type = "aws_sts". AwsSts *AwsStsConfig `json:"awsSts,omitempty" yaml:"awsSts,omitempty"` + + // ClaimInjection contains configuration for the claim injection auth strategy. + // Used when Type = "claim_injection". + ClaimInjection *ClaimInjectionConfig `json:"claimInjection,omitempty" yaml:"claimInjection,omitempty"` } // HeaderInjectionConfig configures the header injection auth strategy. @@ -145,6 +156,24 @@ type UpstreamInjectConfig struct { ProviderName string `json:"providerName" yaml:"providerName"` } +// ClaimInjectionConfig configures the claim injection auth strategy. +// This strategy reads the authenticated user's identity from the request context +// and injects selected claims as X-User-* HTTP headers into outgoing backend requests. +// Backend MCP servers can read these headers to identify the caller without performing +// their own OAuth token introspection or /introspect calls. +// +kubebuilder:object:generate=true +// +gendoc +type ClaimInjectionConfig struct { + // Claims lists which identity claims to inject as X-User-* headers. + // Supported values: "sub", "email", "name" + // - "sub" → X-User-Sub (OIDC subject; immutable across renames) + // - "email" → X-User-Email (user email; mutable, prefer "sub" for stable identity) + // - "name" → X-User-Name (display name) + // Defaults to ["sub"] when empty, injecting only X-User-Sub. + // Including "email" is opt-in to minimise PII forwarded to backends by default. + Claims []string `json:"claims,omitempty" yaml:"claims,omitempty"` +} + // RoleMapping defines a rule for mapping JWT claims to IAM roles. // Mappings are evaluated in priority order (lower number = higher priority). // +kubebuilder:object:generate=true diff --git a/pkg/vmcp/auth/types/zz_generated.deepcopy.go b/pkg/vmcp/auth/types/zz_generated.deepcopy.go index dc67f0f5e5..e20b806dbd 100644 --- a/pkg/vmcp/auth/types/zz_generated.deepcopy.go +++ b/pkg/vmcp/auth/types/zz_generated.deepcopy.go @@ -72,6 +72,11 @@ func (in *BackendAuthStrategy) DeepCopyInto(out *BackendAuthStrategy) { *out = new(AwsStsConfig) (*in).DeepCopyInto(*out) } + if in.ClaimInjection != nil { + in, out := &in.ClaimInjection, &out.ClaimInjection + *out = new(ClaimInjectionConfig) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackendAuthStrategy. @@ -84,6 +89,26 @@ func (in *BackendAuthStrategy) DeepCopy() *BackendAuthStrategy { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClaimInjectionConfig) DeepCopyInto(out *ClaimInjectionConfig) { + *out = *in + if in.Claims != nil { + in, out := &in.Claims, &out.Claims + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClaimInjectionConfig. +func (in *ClaimInjectionConfig) DeepCopy() *ClaimInjectionConfig { + if in == nil { + return nil + } + out := new(ClaimInjectionConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HeaderInjectionConfig) DeepCopyInto(out *HeaderInjectionConfig) { *out = *in diff --git a/pkg/vmcp/config/validator.go b/pkg/vmcp/config/validator.go index e0c30c0cbe..c5898fddc1 100644 --- a/pkg/vmcp/config/validator.go +++ b/pkg/vmcp/config/validator.go @@ -229,6 +229,7 @@ func (*DefaultValidator) validateBackendAuthStrategy(_ string, strategy *authtyp authtypes.StrategyTypeTokenExchange, authtypes.StrategyTypeUpstreamInject, authtypes.StrategyTypeAwsSts, + authtypes.StrategyTypeClaimInjection, } if !slices.Contains(validTypes, strategy.Type) { return fmt.Errorf("type must be one of: %s", strings.Join(validTypes, ", ")) diff --git a/pkg/vmcp/session/internal/backend/mcp_session.go b/pkg/vmcp/session/internal/backend/mcp_session.go index 45e1f534cf..6c547cad4c 100644 --- a/pkg/vmcp/session/internal/backend/mcp_session.go +++ b/pkg/vmcp/session/internal/backend/mcp_session.go @@ -304,6 +304,9 @@ func createMCPClient( // the wire. Restricted header names (Host, hop-by-hop, X-Forwarded-*) are // rejected at resolve time by resolveHeaderForward, so user-supplied // HeaderForward cannot inject them in the first place. + // + // User identity claims (X-User-Sub etc.) are injected by authRoundTripper + // when outgoingAuth.type = "claim_injection" is configured for the backend. // The per-transport sections below may add a size-limiting wrapper on top. base := http.RoundTripper(http.DefaultTransport) base = &authRoundTripper{ diff --git a/pkg/vmcp/session/internal/backend/roundtripper_test.go b/pkg/vmcp/session/internal/backend/roundtripper_test.go index e626d57daa..70bbcea1f9 100644 --- a/pkg/vmcp/session/internal/backend/roundtripper_test.go +++ b/pkg/vmcp/session/internal/backend/roundtripper_test.go @@ -328,3 +328,4 @@ func TestIdentityRoundTripper_FallbackIdentity_InjectionClonesRequest(t *testing require.NotNil(t, base.received) assert.NotSame(t, orig, base.received, "fallback injection should clone the request") } + diff --git a/poc-dockerfile/Dockerfile.vmcp b/poc-dockerfile/Dockerfile.vmcp new file mode 100644 index 0000000000..2280ebfb52 --- /dev/null +++ b/poc-dockerfile/Dockerfile.vmcp @@ -0,0 +1,30 @@ +# Build toolhive vmcp with our ClaimInjectionMiddleware patch +# Context: ~/github/toolhive (must be built from toolhive root) +# +# Build command: +# docker build -f poc/toolhive-poc/Dockerfile.vmcp -t vmcp-poc:latest ~/github/toolhive + +FROM golang:1.26-alpine AS builder +RUN apk add --no-cache git ca-certificates + +WORKDIR /build +# Copy entire toolhive repo (including our ClaimInjectionMiddleware) +COPY . . + +# Build vmcp binary +RUN CGO_ENABLED=0 GOOS=linux go build \ + -ldflags="-s -w -X main.version=poc-dev" \ + -o /vmcp ./cmd/vmcp + +# ---- Runtime image ---- +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates tzdata + +COPY --from=builder /vmcp /usr/local/bin/vmcp + +# Cloud Run / Docker: accept PORT env var +EXPOSE 4483 +ENV PORT=4483 + +ENTRYPOINT ["vmcp"] +CMD ["serve", "--host", "0.0.0.0", "--port", "4483"]