Carry upstream claims across RFC 8693 exchange so delegated tokens work with attribute-based Cedar policies
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 2.2k
- Forks
- 300
- Avg merge
- 1d 15h
- Merged PRs (30d)
- 184
Description
Problem
With a pinned authz.primaryUpstreamProvider, Cedar evaluates policy against claims asserted by that upstream IdP. #6424 fixed the case where an RFC 8693 delegated token was denied outright — it now falls back to the delegated token's own claims, labelled request:no-upstream-session.
That fallback works for policies keyed on the actor, the subject, or the delegated scope:
permit(principal, action == Action::"call_tool", resource) when {
principal.thv_claim_source == "request:no-upstream-session" &&
principal.claim_act.sub == "my-delegate" &&
principal.claim_scope like "*mcp:tools*"
};
It does not work for policies keyed on upstream attributes. A delegated token carries at most sub, act, client_id, scope, name and email. No groups, no roles, no department — nothing the pinned IdP asserted about the subject. A rule guarded on principal has claim_department stops matching, so the request is denied. Same user-visible outcome as the original bug (tools vanish from tools/list), reached at policy evaluation instead of claim resolution.
So delegation under a pinned provider is currently usable only by deployments whose policies are identity-light.
Two secondary effects of the missing attributes:
- A
forbidguarded on an upstream attribute never fires, because itshasguard is false. The restriction lifts rather than failing closed. - With multiple upstreams,
claim_email/claim_nameon a delegated token are copied from the subject token and mirror the first configured upstream, which need not be the pinned one.
Both are documented as caveats in docs/authz.md today, but they are workarounds, not a fix.
Proposal: claims transcription
Carry the upstream-asserted attributes across the exchange while keeping the credential severed. This is a named pattern — draft-ietf-oauth-identity-chaining §2.5, whose stated purpose is propagating "user and client identifiers, authorization context, and other relevant information across trust boundaries."
Sketch: at exchange time, the authorization server writes the upstream claims it already holds for the subject's session into the delegated token under one namespaced claim, keyed by provider:
"https://toolhive.dev/upstream_claims": {
"github": { "groups": ["platform"], "department": "engineering" },
"google": { "...": "..." }
}
The resource server already knows its own primaryUpstreamProvider and selects from that map. No Cedar configuration needs to reach the authorization server, so the layering stays intact.
Explicitly out of scope
Do not carry the upstream session pointer (tsid) through the exchange. That would hand the delegate the user's live upstream credential, usable for outbound token injection, bound to the user's session with no agent dimension — so the agent's access could not be revoked independently of the user's. This is contrary to how Entra OBO, AWS AgentCore's token vault, and the ID-JAG / transaction-token drafts all handle the same problem: the actor gets its own audience-scoped artifact, never a replay of the subject's credential. Severing the link is correct and should stay.
Transcription moves attributes, which are facts about the subject. Authority stays bounded by the existing scope intersection in grantScopes.
Open questions
- Opaque upstream access tokens. When the upstream credential is opaque there are no claims to transcribe. This is the existing
request:upstream-opaquepath; it needs a decision rather than inheriting one. - Claim source label. Transcribed claims are not live upstream claims. They likely need their own
thv_claim_sourcevalue (something likeupstream:<provider>:transcribed) so a policy can distinguish a snapshot from a live assertion. - Staleness. A transcribed claim is fixed at exchange time and does not follow a refresh. Bounded today by
computeLifetime, which caps the delegated token at the subject token's remaining lifetime — is that bound tight enough? - Token size. Upstream claim sets can be large. The exchange handler already caps the
actchain at 8KB; transcription needs a comparable bound and a minimization rule for which claims are worth carrying. - Selection. Transcribe all providers, or let the authorization server be told which subset matters?
Acceptance
- A delegated token under a pinned
primaryUpstreamProviderauthorizes against a policy keyed on an upstream attribute, matching the decision the subject's own token would receive for the same action. - A conformance test pinning the attenuation invariant: a subject token with broader upstream authority, exchanged, must not let Cedar reach authority available only to the subject. Requested in https://github.com/stacklok/toolhive/issues/6424#issuecomment-5401328084.
tsidremains severed; no test relies on the delegate holding the subject's upstream credential.
Context
Follow-up to #6424 / #6506. The caveats above are documented in docs/authz.md under "Tokens with no upstream login".
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with docs/authz.md under “Tokens with no upstream login,” then trace the RFC 8693 exchange handler and the existing grantScopes and computeLifetime paths. Define the transcription and claim-source behavior before implementation, and verify the acceptance cases: upstream-attribute authorization, attenuation, and continued tsid severing.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- authorization, security
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100