Bug description
When a workload pins an upstream provider (primaryUpstreamProvider, forced by the
runner whenever the embedded auth server is active), Cedar resolves policy claims
along two different code paths depending on whether the upstream's access token
happens to be JWT-shaped. The two paths disagree about both the claim source and the
Cedar principal, so the same policy can behave differently for two providers that
are otherwise configured identically.
resolveClaims in pkg/authz/authorizers/cedar/core.go:
Two consequences, both silent:
1. The principal is not the upstream subject on the opaque path. The AS-issued
sub is an internal user ID — a fresh UUID from UserResolver
(pkg/authserver/server/handlers/user.go), not the upstream subject, which travels
separately as UpstreamSubject (pkg/authserver/server/handlers/callback.go). So a
rule written as
forbid(principal == Client::"okta|alice", action, resource);
matches with a JWT-issuing upstream and silently does not match with an
opaque-token upstream. Cedar has no has-style guard in the principal position, so
there is no diagnostic — the request simply carries a different principal, which a
broad permit may legitimately match.
2. Claim provenance is wrong on the opaque path in multi-upstream chains. The
profile claims the auth server mirrors into its own token always come from the
first configured upstream: validateChain
(pkg/authserver/server/handlers/handler.go) requires chain[0] to be
upstreams[0], and only the first leg resolves identity from its provider
(userEmail = result.Email, callback.go), with later legs carrying those values
forward. primaryUpstreamProvider may name any upstream — the operator validates
membership, not position, and pinning a non-first provider is documented in the
vMCP guide. So with upstreamProviders: [okta, github] and
primaryUpstreamProvider: github, the opaque path feeds okta's email into a
claim set the policy reads as github's. verifyChainIdentity guarantees it is the
same canonical user, so this is not another person's identity — but a gate
distinguishing a corporate IdP from a personal one is exactly the policy it
subverts.
This is the same defect #6036 removed from the JWT path; #5147 predates that
reasoning, so the opaque branch still has it.
Steps to reproduce
For (1), the shortest demonstration is a unit test rather than a deployment: give
TestAuthorizeWithJWTClaims_PrincipalStaysUpstreamSourced
(pkg/authz/authorizers/cedar/core_test.go) a case whose UpstreamTokens entry is
an opaque string such as ya29.opaque instead of a JWT. The
forbid(principal == Client::"hub|banned-alice", ...) rule stops matching and the
broad permit applies.
End to end: run two workloads with the embedded auth server, one behind an upstream
that issues JWT access tokens and one behind Google or GitHub, with the same Cedar
policy keyed on Client::"<upstream-subject>". The rule applies on the first and
not the second.
Expected behavior
The claim source and the principal should not depend on the wire format of the
upstream access token. Both paths should read the pinned provider's own credentials,
so the principal is the upstream subject and profile claims carry that provider's
provenance regardless of token shape.
Actual behavior
The opaque path evaluates the ToolHive-issued token's claims and derives the
principal from ToolHive's internal user ID.
Additional context
The fix direction, given what #6036 established: the opaque branch should supplement
from the pinned provider's id_token (identity.UpstreamIDTokens[provider]) rather
than from identity.Claims. An OIDC upstream always has one — upstream/oidc.go
rejects a login without an id_token and force-adds the openid scope — and it
survives refresh (upstreamtoken/service.go carries the original forward when a
refresh returns none). exp should deliberately not be enforced, for the reasons
now documented on Identity.UpstreamIDTokens.
Two things make this more than a tidy-up, and also mean it needs its own change
rather than riding along with #6036:
- It is a behaviour change on a shipped path. Google and GitHub deployments would
see the Cedar principal change from Client::"<uuid>" to
Client::"<upstream-subject>". Any policy keyed on the UUID form would stop
matching. That is unlikely to be deliberate — nobody sensibly writes a policy
against a random UUID — but it is a real migration consideration and deserves a
release note.
- A pure OAuth 2.0 upstream never asked for
openid has no stored id_token, so it
has no provenance-correct source for name/email at all. That case should fall
back to today's behaviour or fail closed, deliberately chosen and documented, not
left implicit.
Current behaviour is pinned by opaque_upstream_token_still_falls_back in
pkg/authz/integration_test.go, with a comment recording that the inconsistency is
intentional-for-now — that test will need updating as part of the fix.
Related: #5916, #5147, #6022, #6036.
Bug description
When a workload pins an upstream provider (
primaryUpstreamProvider, forced by therunner whenever the embedded auth server is active), Cedar resolves policy claims
along two different code paths depending on whether the upstream's access token
happens to be JWT-shaped. The two paths disagree about both the claim source and the
Cedar principal, so the same policy can behave differently for two providers that
are otherwise configured identically.
resolveClaimsinpkg/authz/authorizers/cedar/core.go:provider's access token, supplemented for
name/emailfrom the same provider'sid_token. The Cedar principal is the upstream subject.ya29.*, GitHubgho_*— the!looksLikeJWTfallback added in Fall back to request-token claims for opaque upstream tokens #5147): claims come from the ToolHive-issued token the client
presented. The Cedar principal is
Client::"<ToolHive internal user ID>".Two consequences, both silent:
1. The principal is not the upstream subject on the opaque path. The AS-issued
subis an internal user ID — a fresh UUID fromUserResolver(
pkg/authserver/server/handlers/user.go), not the upstream subject, which travelsseparately as
UpstreamSubject(pkg/authserver/server/handlers/callback.go). So arule written as
matches with a JWT-issuing upstream and silently does not match with an
opaque-token upstream. Cedar has no
has-style guard in the principal position, sothere is no diagnostic — the request simply carries a different principal, which a
broad
permitmay legitimately match.2. Claim provenance is wrong on the opaque path in multi-upstream chains. The
profile claims the auth server mirrors into its own token always come from the
first configured upstream:
validateChain(
pkg/authserver/server/handlers/handler.go) requireschain[0]to beupstreams[0], and only the first leg resolves identity from its provider(
userEmail = result.Email,callback.go), with later legs carrying those valuesforward.
primaryUpstreamProvidermay name any upstream — the operator validatesmembership, not position, and pinning a non-first provider is documented in the
vMCP guide. So with
upstreamProviders: [okta, github]andprimaryUpstreamProvider: github, the opaque path feeds okta's email into aclaim set the policy reads as github's.
verifyChainIdentityguarantees it is thesame canonical user, so this is not another person's identity — but a gate
distinguishing a corporate IdP from a personal one is exactly the policy it
subverts.
This is the same defect #6036 removed from the JWT path; #5147 predates that
reasoning, so the opaque branch still has it.
Steps to reproduce
For (1), the shortest demonstration is a unit test rather than a deployment: give
TestAuthorizeWithJWTClaims_PrincipalStaysUpstreamSourced(
pkg/authz/authorizers/cedar/core_test.go) a case whoseUpstreamTokensentry isan opaque string such as
ya29.opaqueinstead of a JWT. Theforbid(principal == Client::"hub|banned-alice", ...)rule stops matching and thebroad
permitapplies.End to end: run two workloads with the embedded auth server, one behind an upstream
that issues JWT access tokens and one behind Google or GitHub, with the same Cedar
policy keyed on
Client::"<upstream-subject>". The rule applies on the first andnot the second.
Expected behavior
The claim source and the principal should not depend on the wire format of the
upstream access token. Both paths should read the pinned provider's own credentials,
so the principal is the upstream subject and profile claims carry that provider's
provenance regardless of token shape.
Actual behavior
The opaque path evaluates the ToolHive-issued token's claims and derives the
principal from ToolHive's internal user ID.
Additional context
The fix direction, given what #6036 established: the opaque branch should supplement
from the pinned provider's
id_token(identity.UpstreamIDTokens[provider]) ratherthan from
identity.Claims. An OIDC upstream always has one —upstream/oidc.gorejects a login without an id_token and force-adds the
openidscope — and itsurvives refresh (
upstreamtoken/service.gocarries the original forward when arefresh returns none).
expshould deliberately not be enforced, for the reasonsnow documented on
Identity.UpstreamIDTokens.Two things make this more than a tidy-up, and also mean it needs its own change
rather than riding along with #6036:
see the Cedar principal change from
Client::"<uuid>"toClient::"<upstream-subject>". Any policy keyed on the UUID form would stopmatching. That is unlikely to be deliberate — nobody sensibly writes a policy
against a random UUID — but it is a real migration consideration and deserves a
release note.
openidhas no stored id_token, so ithas no provenance-correct source for
name/emailat all. That case should fallback to today's behaviour or fail closed, deliberately chosen and documented, not
left implicit.
Current behaviour is pinned by
opaque_upstream_token_still_falls_backinpkg/authz/integration_test.go, with a comment recording that the inconsistency isintentional-for-now — that test will need updating as part of the fix.
Related: #5916, #5147, #6022, #6036.