You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
Validates the ToolHive-issued JWT
Reads the tsid (token session ID) claim and looks up upstream IDP tokens from storage
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:
ifconfig.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.
Operator path — buildOutgoingAuthConfig (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.// +optionalSubjectProviderNamestring`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.
Summary
When a
VirtualMCPServeruses the embedded authorization server alongside atoken_exchangeoutgoing auth strategy, the strategy silently falls back to usingidentity.Token(the ToolHive-issued JWT) as the RFC 8693 subject token — rather than the upstream IDP token stored inidentity.UpstreamTokens. In practice the exchange endpoint will reject the ToolHive JWT, but the failure mode is opaque: the error won't indicate thatsubjectProviderNameneeds to be set.The same class of bug was fixed for Cedar authorization policies in #4448, which introduced
injectUpstreamProviderIfNeededto auto-populatePrimaryUpstreamProviderfrom the embedded auth server config. The token_exchange strategy needs the same treatment.Background
When the embedded auth server is active, the auth middleware:
tsid(token session ID) claim and looks up upstream IDP tokens from storageidentity.UpstreamTokens[providerName]with the upstream access tokensThe vMCP token_exchange strategy (
pkg/vmcp/auth/strategies/tokenexchange.go:122-133) selects the subject token like this:SubjectProviderNameis a user-configurable field in both theMCPExternalAuthConfigCRD (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. Theupstreamswapmiddleware runs beforetokenexchangein the pipeline and already replaces the Authorization header with the upstream token. The issue is specific to the vMCP strategy path, which readsidentity.Tokendirectly.Proposed fix
Auto-populate
SubjectProviderNamefrom the embedded auth server config when it is empty, mirroring the Cedar fix from #4448.Cedar analog (reference implementation)
pkg/runner/middleware.go:331-354—injectUpstreamProviderIfNeeded:Where to add the injection
Operator path —
buildOutgoingAuthConfig(cmd/thv-operator/controllers/virtualmcpserver_controller.go:1954) callsconvertBackendAuthConfigToVMCPfor each backend strategy. After conversion, ifvmcp.Spec.AuthServerConfigis set and the resulting strategy istoken_exchangewith an emptySubjectProviderName, auto-populate it using the first upstream fromvmcp.Spec.AuthServerConfig.Upstreams(normalized withauthserver.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 anauthServerblock is present in the vMCP config alongside an outgoingtoken_exchangestrategy with nosubjectProviderName, auto-populate from the first configured upstream.CRD field comment
The
subjectProviderNamefield in theMCPExternalAuthConfigCRD (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:The same clarification should be added to the
SubjectProviderNamefield inpkg/vmcp/auth/types/types.go:113.Acceptance criteria
VirtualMCPServerand atoken_exchangeoutgoing strategy hassubjectProviderNameleft empty, the system auto-populates it with the first upstream provider name (same resolution logic as Cedar'sPrimaryUpstreamProvider).subjectProviderNamevalues are never overridden.subjectProviderNamefield comment in the CRD type and theauthtypes.TokenExchangeConfigstruct document the auto-population behavior.TestInjectUpstreamProviderIfNeededpattern) and the YAML path.Related
PrimaryUpstreamProviderauto-injection (the direct analog)pkg/runner/middleware.go:331—injectUpstreamProviderIfNeededreference implementationpkg/vmcp/auth/strategies/tokenexchange.go:122— token selection logic to understand the fallbackcmd/thv-operator/controllers/virtualmcpserver_controller.go:1954—buildOutgoingAuthConfig, where injection belongs for operator pathcmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go:137— CRD field to update with auto-population commentpkg/vmcp/auth/types/types.go:113— runtime type field to update with auto-population comment