Skip to content

Cedar authz with embedded auth server silently denies everything when the upstream IdP issues JWT access tokens without the referenced identity claims (e.g. email) #5916

Description

@alex-feel

Bug description

When a workload uses the embedded auth server, the Cedar authorization middleware sources the principal's claims from the raw upstream IdP access token instead of the AS-issued JWT that the client actually presented. If the upstream provider's access tokens are JWT-shaped but do not carry the identity claims that policies reference (many OIDC providers put email only in the id_token, not the access token — JetBrains Hub is a concrete public example), every Cedar policy referencing those claims silently denies all requests, while the audit middleware keeps logging the correct user identity from the AS-issued JWT.

Code chain (file:line at v0.34.0, commit b084be45; byte-identical in the relevant functions at v0.37.0 and at main = 193a7f29d40f03e8c5276f7abc7da89f3342e758, checked 2026-07-22):

  1. pkg/runner/middleware.go:192injectUpstreamProviderIfNeeded (pkg/runner/middleware.go:468) sets the Cedar authorizer's PrimaryUpstreamProvider to the first upstream whenever config.EmbeddedAuthServerConfig != nil. There is no opt-out.
  2. cedar.InjectUpstreamProvider (pkg/authz/authorizers/cedar/core.go:79) overwrites any user-supplied value unconditionally (core.go:94). The CRD field primaryUpstreamProvider is documented as "silently ignored" on MCPServer and MCPRemoteProxy (cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go:420, docs/operator/crd-api.md), so on those kinds a user cannot select the claim source at all (only VirtualMCPServer gained an explicit field via Expose explicit primaryUpstreamProvider for Cedar authz on VirtualMCPServer #5197).
  3. resolveClaims (pkg/authz/authorizers/cedar/core.go:486) then reads identity.UpstreamTokens[<provider>] (core.go:489), parses it without signature verification (parseUpstreamJWTClaims, core.go:559, jwt.Parser.ParseUnverified), and uses those claims as the Cedar principal attributes (prefixed claim_ by preprocessClaims, core.go:585-590).
  4. The fallback to the AS-issued JWT's claims added in Fall back to request-token claims for opaque upstream tokens #5147 triggers only when the upstream token is not JWT-shaped: looksLikeJWT (core.go:537) checks for exactly two dots, and the fallback branch is gated on !looksLikeJWT(upstreamToken) (core.go:512-520). A JWT-shaped upstream access token that parses fine but lacks the referenced claims never falls back. (A missing upstream token is likewise a hard deny at core.go:489-494 — same silent-deny class.)

The frustrating part: the identity data the policy needs is already available in identity.Claims. The embedded auth server mirrors the upstream OIDC sub/name/email into its issued access token (pkg/authserver/server/session/session.go:133-137), and the audit middleware reads exactly that identity (pkg/audit/auditor.go:463-473, :493), which is why audit logs show the correct user while authorization denies the same request.

Steps to reproduce

  1. Run a workload with the embedded auth server and a single OIDC upstreamProviders entry whose access tokens are JWTs that do not contain email (JetBrains Hub behaves this way: the access token is a JWT without an email claim; the email is present only in the id_token). Reproducible on Kubernetes with MCPServer/MCPRemoteProxy, and the same code path is shared by the CLI runner.
  2. Attach a Cedar authorization config with a domain gate plus a tool permit — note the forbid policy is even authored defensively with has, as recommended by the code comment at core.go:508-511:
authzConfig:
  inline:
    policies:
      - |
        forbid(principal, action, resource)
        unless { principal has claim_email && principal.claim_email like "*@example.com" };
      - |
        permit(principal, action == Action::"call_tool", resource);
  1. Complete the OAuth flow through the embedded auth server with a user whose upstream email matches the gated domain, then issue tools/list and tools/call with the AS-issued bearer token.

Expected behavior

The domain gate is evaluated against the user's email. The AS-issued JWT presented by the client carries email (mirrored from the upstream id_token by the embedded auth server), so the user sees the tool list and can call tools.

Actual behavior

Everything is denied, silently and for every user:

  • tools/list returns {"tools":[]} — the middleware forwards list requests and filters each item against the call_tool policy, and every item fails the gate.
  • tools/call returns HTTP 403 with body {"Result":null,"Error":{"code":403,"message":"Unauthorized"},"ID":{}} (the forbid policy applies cleanly because principal has claim_email is false against the upstream access token's claims, so there is no Cedar diagnostic error and no hint in the response).
  • The audit middleware logs the correct user for the same denied requests (audit reads the AS-issued JWT identity), so audit and authz visibly diverge, which is confusing to operate.
  • No warning is logged: the "upstream token is not a JWT; falling back to request-token claims" warning fires only for opaque tokens.

Why opaque-token providers mask this

With upstreams whose access tokens are opaque (Google ya29.*, GitHub gho_*), parseUpstreamJWTClaims fails, looksLikeJWT is false, and the authorizer falls back to the AS-issued JWT's claims — which do carry the mirrored email. So claim_email policies appear to work with Google/GitHub upstreams and break only when the upstream happens to issue JWT-shaped access tokens, which makes the behavior look provider-specific and hard to diagnose.

Environment (if relevant)

  • ToolHive: observed live at v0.34.0 (operator-managed Kubernetes deployment); the code chain above is unchanged at v0.37.0 (git diff v0.34.0 v0.37.0 -- pkg/authz pkg/runner/middleware.go pkg/auth shows no change to claim resolution) and at main@193a7f29d40f03e8c5276f7abc7da89f3342e758.
  • Upstream IdP: any OIDC provider issuing JWT-shaped access tokens without email (JetBrains Hub as a public example).

Additional context

Related prior work, none of which covers this case:

Suggested fix directions (any one of these resolves the report; the first addresses the root cause):

  1. Merge instead of replace: evaluate the upstream token's claims merged with the AS-issued JWT's claims (for example upstream claims win per key, AS-JWT claims fill the gaps). The AS-JWT already mirrors the upstream sub/email/name (pkg/authserver/server/session/session.go), so standard OIDC identity policies would keep working for every upstream token shape while upstream-only claims (groups, custom namespaced claims) remain available.
  2. Extend the Fall back to request-token claims for opaque upstream tokens #5147 fallback: when a JWT-shaped upstream token parses successfully but lacks the standard identity claims (or lacks the attributes the policy set references), fall back to — or supplement with — the request-token claims instead of hard-committing to the upstream token.
  3. Make the claim source selectable: honor primaryUpstreamProvider on MCPServer/MCPRemoteProxy including an explicit way to opt out of upstream-token evaluation (parity with what Expose explicit primaryUpstreamProvider for Cedar authz on VirtualMCPServer #5197 delivered for VirtualMCPServer), instead of force-injecting the first upstream.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-triageIssue needs initial triage by a maintainer

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions