Skip to content

RFC 8693 delegated tokens are denied all tools when Cedar authz uses an upstream provider as its primary claim source #6424

Description

@jhrozek

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:

  1. Complete a normal browser/PKCE login to obtain a subject token (T1).
  2. tools/list with T1 → 1 tool.
  3. 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.
  4. 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:

  1. 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.
  2. 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.
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    authenticationbugSomething isn't workingneeds-triageIssue needs initial triage by a maintainer

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions