Bug description
When a VirtualMCPServer combines an embedded auth server that has upstreamProviders with any Cedar authzConfig, the operator auto-derives authz.primaryUpstreamProvider from the sole upstream provider. The Cedar authorizer then treats the upstream IdP token as its primary claim source (pkg/authz/authorizers/cedar/core.go:607-623):
upstreamToken, tokenFound := identity.UpstreamTokens[a.primaryUpstreamProvider]
if !tokenFound || upstreamToken == "" {
return nil, fmt.Errorf("upstream token for provider %q not found in identity",
a.primaryUpstreamProvider)
}
A token minted through RFC 8693 token exchange has no UpstreamTokens[...] entry — that binding only exists for an identity established via the upstream browser flow. So resolveClaims fails closed for every delegated token, and every tool is filtered out before policy evaluation.
The result is that RFC 8693 delegation and upstream-claims-based Cedar authorization are mutually exclusive, with no configuration that satisfies both.
Reproduction
ToolHive v0.44.0 (operator image ghcr.io/stacklok/toolhive/operator:v0.44.0, charts 0.44.0), Kind.
VirtualMCPServer with an embedded auth server, one upstream provider (dex), one backend, a confidential delegateClients entry, and a deliberately permissive policy:
incomingAuth:
type: oidc
oidcConfigRef: {name: vmcp-embedded-auth, audience: "…/mcp", resourceUrl: "…/mcp"}
authzConfig:
type: inline
inline:
policies:
- permit(principal, action, resource);
authServerConfig:
issuer: …
upstreamProviders:
- name: dex
type: oauth2
…
delegateClients:
- clientId: my-delegate
…
primaryUpstreamProvider is not set anywhere in the spec. The operator injects it into the generated config.yaml:
authz:
type: cedar
policies:
- permit(principal, action, resource);
entitiesJson: '[]'
primaryUpstreamProvider: dex
Then:
- Complete a normal browser/PKCE login to obtain a subject token (T1).
tools/list with T1 → 1 tool.
- Exchange T1 for a delegated token (T2) via
urn:ietf:params:oauth:grant-type:token-exchange as the confidential delegate client. The exchange succeeds and returns a correctly attenuated token.
tools/list with T2 → 0 tools.
T2 is accepted at the transport layer — initialize returns 200 and a session is established — so this is not an authentication failure. vMCP logs:
WARN admission: tool authorization check failed, skipping
tool=yardstick_echo
error="upstream token for provider \"dex\" not found in identity"
Note the policy is permit(principal, action, resource) — it permits everything and is never reached. The failure is in claim resolution, before evaluation.
Expected behavior
A delegated token should be authorizable. Some possibilities, in rough order of preference:
- Fall back to the request token's claims when the identity has no upstream token, rather than failing closed — the delegated token carries
sub, scp, act, client_id, and (here) email/name, which is enough for most policies. The existing default path (primaryUpstreamProvider == "") already does exactly this.
- Make
primaryUpstreamProvider explicitly overridable to empty, so an operator can opt into request-token claims when they know delegated tokens are in play. It is currently auto-derived with no documented way to suppress it.
- Carry the originating identity's upstream token through the exchange — richer, but a much larger change, and arguably wrong: the whole point of delegation is that the actor is not the original client.
At minimum this interaction should be documented, and ideally surfaced as a status condition or startup warning rather than a per-call WARN that presents as "tool mysteriously missing".
Impact
Any deployment wanting both delegated access (agents/services acting for a user) and Cedar authorization is currently stuck: the tokens authenticate but see an empty capability set. The failure mode is quiet — tools vanish from tools/list with no client-visible error — which is hard to diagnose without server logs.
Current workaround
Remove authzConfig entirely. Where the policy was permit-all this costs no security, and authentication via incomingAuth is unaffected. It is not a workaround for anyone who needs real policies.
Scope
Bug description
When a
VirtualMCPServercombines an embedded auth server that hasupstreamProviderswith any CedarauthzConfig, the operator auto-derivesauthz.primaryUpstreamProviderfrom the sole upstream provider. The Cedar authorizer then treats the upstream IdP token as its primary claim source (pkg/authz/authorizers/cedar/core.go:607-623):A token minted through RFC 8693 token exchange has no
UpstreamTokens[...]entry — that binding only exists for an identity established via the upstream browser flow. SoresolveClaimsfails closed for every delegated token, and every tool is filtered out before policy evaluation.The result is that RFC 8693 delegation and upstream-claims-based Cedar authorization are mutually exclusive, with no configuration that satisfies both.
Reproduction
ToolHive
v0.44.0(operator imageghcr.io/stacklok/toolhive/operator:v0.44.0, charts0.44.0), Kind.VirtualMCPServerwith an embedded auth server, one upstream provider (dex), one backend, a confidentialdelegateClientsentry, and a deliberately permissive policy:primaryUpstreamProvideris not set anywhere in the spec. The operator injects it into the generatedconfig.yaml:Then:
tools/listwith T1 → 1 tool.urn:ietf:params:oauth:grant-type:token-exchangeas the confidential delegate client. The exchange succeeds and returns a correctly attenuated token.tools/listwith T2 → 0 tools.T2 is accepted at the transport layer —
initializereturns 200 and a session is established — so this is not an authentication failure. vMCP logs:Note the policy is
permit(principal, action, resource)— it permits everything and is never reached. The failure is in claim resolution, before evaluation.Expected behavior
A delegated token should be authorizable. Some possibilities, in rough order of preference:
sub,scp,act,client_id, and (here)email/name, which is enough for most policies. The existing default path (primaryUpstreamProvider == "") already does exactly this.primaryUpstreamProviderexplicitly overridable to empty, so an operator can opt into request-token claims when they know delegated tokens are in play. It is currently auto-derived with no documented way to suppress it.At minimum this interaction should be documented, and ideally surfaced as a status condition or startup warning rather than a per-call
WARNthat presents as "tool mysteriously missing".Impact
Any deployment wanting both delegated access (agents/services acting for a user) and Cedar authorization is currently stuck: the tokens authenticate but see an empty capability set. The failure mode is quiet — tools vanish from
tools/listwith no client-visible error — which is hard to diagnose without server logs.Current workaround
Remove
authzConfigentirely. Where the policy was permit-all this costs no security, and authentication viaincomingAuthis unaffected. It is not a workaround for anyone who needs real policies.Scope
upstreamProvidersentry, and a CedarauthzConfig. Remove any one and delegated tokens authorize normally.