Skip to content

Auto-populate SubjectProviderName in vMCP token_exchange strategy when embedded auth server is active #4528

Description

@tgrunnagle

Summary

When a VirtualMCPServer uses the embedded authorization server alongside a token_exchange outgoing auth strategy, the strategy silently falls back to using identity.Token (the ToolHive-issued JWT) as the RFC 8693 subject token — rather than the upstream IDP token stored in identity.UpstreamTokens. In practice the exchange endpoint will reject the ToolHive JWT, but the failure mode is opaque: the error won't indicate that subjectProviderName needs to be set.

The same class of bug was fixed for Cedar authorization policies in #4448, which introduced injectUpstreamProviderIfNeeded to auto-populate PrimaryUpstreamProvider from the embedded auth server config. The token_exchange strategy needs the same treatment.

Background

When the embedded auth server is active, the auth middleware:

  1. Validates the ToolHive-issued JWT
  2. Reads the tsid (token session ID) claim and looks up upstream IDP tokens from storage
  3. Populates identity.UpstreamTokens[providerName] with the upstream access tokens

The vMCP token_exchange strategy (pkg/vmcp/auth/strategies/tokenexchange.go:122-133) selects the subject token like this:

if config.SubjectProviderName != "" {
    subjectToken = identity.UpstreamTokens[config.SubjectProviderName]
} else {
    subjectToken = identity.Token  // ← ToolHive JWT when embedded auth server is active
}

SubjectProviderName is a user-configurable field in both the MCPExternalAuthConfig CRD (cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go:137-140) and the vMCP YAML config (pkg/vmcp/auth/types/types.go:113-116). The field exists and works — but forgetting to set it when using the embedded auth server is a silent footgun.

Note: the classic proxy path (pkg/auth/tokenexchange/middleware.go) does not need this fix. The upstreamswap middleware runs before tokenexchange in the pipeline and already replaces the Authorization header with the upstream token. The issue is specific to the vMCP strategy path, which reads identity.Token directly.

Proposed fix

Auto-populate SubjectProviderName from the embedded auth server config when it is empty, mirroring the Cedar fix from #4448.

Cedar analog (reference implementation)

pkg/runner/middleware.go:331-354injectUpstreamProviderIfNeeded:

func injectUpstreamProviderIfNeeded(
    authzCfg *authz.Config,
    embeddedCfg *authserver.RunConfig,
) (*authz.Config, error) {
    if embeddedCfg == nil {
        return authzCfg, nil
    }
    providerName := func() string {
        if len(embeddedCfg.Upstreams) > 0 {
            return authserver.ResolveUpstreamName(embeddedCfg.Upstreams[0].Name)
        }
        return authserver.DefaultUpstreamName
    }()
    return cedar.InjectUpstreamProvider(authzCfg, providerName)
}

Where to add the injection

Operator pathbuildOutgoingAuthConfig (cmd/thv-operator/controllers/virtualmcpserver_controller.go:1954) calls convertBackendAuthConfigToVMCP for each backend strategy. After conversion, if vmcp.Spec.AuthServerConfig is set and the resulting strategy is token_exchange with an empty SubjectProviderName, auto-populate it using the first upstream from vmcp.Spec.AuthServerConfig.Upstreams (normalized with authserver.ResolveUpstreamName, same as Cedar).

The injection should live in a helper analogous to injectUpstreamProviderIfNeeded, e.g. injectSubjectProviderIfNeeded(strategy *authtypes.BackendAuthStrategy, embeddedCfg *mcpv1alpha1.EmbeddedAuthServerConfig) *authtypes.BackendAuthStrategy.

The injection must be applied to both the default strategy (outgoing.Default) and each per-backend strategy (outgoing.Backends[name]).

vMCP YAML path — The same logic should apply in the YAML config layer (pkg/vmcp/config/). If an authServer block is present in the vMCP config alongside an outgoing token_exchange strategy with no subjectProviderName, auto-populate from the first configured upstream.

CRD field comment

The subjectProviderName field in the MCPExternalAuthConfig CRD (cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go:137) should be updated to document the auto-population fallback so that users reading the CRD spec understand the behavior without needing to trace the controller code:

// SubjectProviderName is the name of the upstream provider whose token is used as the
// RFC 8693 subject token instead of identity.Token when performing token exchange.
// When left empty and an embedded authorization server is configured on the VirtualMCPServer,
// the controller automatically populates this field with the first configured upstream
// provider name. Set it explicitly to override that default or to select a specific
// provider when multiple upstreams are configured.
// +optional
SubjectProviderName string `json:"subjectProviderName,omitempty"`

The same clarification should be added to the SubjectProviderName field in pkg/vmcp/auth/types/types.go:113.

Acceptance criteria

  • When an embedded auth server is configured on a VirtualMCPServer and a token_exchange outgoing strategy has subjectProviderName left empty, the system auto-populates it with the first upstream provider name (same resolution logic as Cedar's PrimaryUpstreamProvider).
  • Explicit subjectProviderName values are never overridden.
  • The YAML config path applies the same logic.
  • The subjectProviderName field comment in the CRD type and the authtypes.TokenExchangeConfig struct document the auto-population behavior.
  • Unit tests cover the injection for the operator path (modeled on TestInjectUpstreamProviderIfNeeded pattern) and the YAML path.

Related

  • Enforce Cedar policies on upstream IDP token claims #4448 — Cedar PrimaryUpstreamProvider auto-injection (the direct analog)
  • pkg/runner/middleware.go:331injectUpstreamProviderIfNeeded reference implementation
  • pkg/vmcp/auth/strategies/tokenexchange.go:122 — token selection logic to understand the fallback
  • cmd/thv-operator/controllers/virtualmcpserver_controller.go:1954buildOutgoingAuthConfig, where injection belongs for operator path
  • cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go:137 — CRD field to update with auto-population comment
  • pkg/vmcp/auth/types/types.go:113 — runtime type field to update with auto-population comment

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

authenticationauthorizationenhancementNew feature or requestgoPull requests that update go codevmcpVirtual MCP Server related issues

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions