stacklok / stacklok/toolhive

Use the pinned provider's id_token when its access token is opaque

Open
#6,655 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

needs-triage
Dominant language
Go
Stars
2.2k
Forks
300
Avg merge
1d 15h
Merged PRs (30d)
184

Description

Problem

resolveClaims guards against using the ToolHive-issued token's mirrored email/name when a provider is pinned. The test at pkg/authz/authorizers/cedar/core_test.go:1880 is named "the multi-upstream misattribution guard" and states the mirror "must NOT stand in, because in a chain it belongs to the first configured upstream, not this one."

That guard only applies on the path where the upstream access token parses as a JWT. On the opaque branch (pkg/authz/authorizers/cedar/core.go:707-716) the function returns requestClaims — the mirror — and never reads UpstreamIDTokens[provider], even though it is present for any OIDC upstream. supplementFromIDToken has a single call site, the parse-success path at core.go:721.

So we enforce the guard on one branch and violate it on the other.

Impact

Behavioral, not a security boundary. thv_claim_source already reports the degradation, docs/authz.md:357 documents it, and the pinned provider defaults to upstreams[0] — where the mirror and the id_token carry the same value from the same provider, so nothing is misattributed.

It matters in two narrower shapes:

  • Multi-upstream chain with an explicit pin to a non-first upstream. Policies see the first upstream's email/name rather than the pinned provider's.
  • Upstreams that assert profile claims only in the id_token. Profile-claim policies deny although the assertion is available — the #5916 deny-all shape, on the branch #6022 did not cover.
Proposed fix

Supplement email/name from the pinned provider's stored id_token on the opaque branch, keeping sub from the request token.

Do not simply call supplementFromIDToken(identity, jwt.MapClaims{}) and return that. profileClaimsFromIDToken is {name, email}sub is deliberately excluded (core.go:801) — so the result would carry no sub, extractClientIDFromClaims (core.go:878-886) would fail, and every request on this path would return ErrMissingPrincipal. That turns a working path into deny-all, which is worse than the current behavior.

Sketch:

  • Start from the request token's sub only, so the Cedar principal entity ID is unchanged.
  • Supplement email/name from UpstreamIDTokens[provider].
  • Give it a new claim-source label. The result is a hybrid — principal from the request token, profile claims from the pinned upstream — so it is neither upstream:<provider> nor today's request:upstream-opaque. Reusing upstream:<provider> would also break the opaque_fallback_does_not_pass_as_upstream test, which exists to keep the degraded path from impersonating a healthy one.
  • No stored id_token for the provider: keep today's behavior and label.
  • Leave the fail-closed path for JWT-shaped-but-unparsable tokens (core.go:717-718) untouched.
Notes
  • This newly permits. Any policy currently denying because profile claims were absent, or holding the mirror's value, starts matching. Needs a release note.
  • Whether other request-token claims besides sub should carry over is an open question — dropping them changes which policies match. Worth deciding explicitly rather than by accident.
  • Decide whether the new label should appear in the docs/authz.md:349 table as a fifth value, and whether the multi-upstream warning at docs/authz.md:425-430 moves out of the delegation section, since it applies here too.
Context

Reported via GHSA-ch9x-m6fv-82vp, closed as informative — the opaque fallback itself is intentional, added in #5147 (closing #5146) and disclosed through thv_claim_source, the docs, and a WARN log. The branch inconsistency above is the part worth fixing. Reporter: @Yanhaoxi.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in pkg/authz/authorizers/cedar/core.go:707-721 and compare the opaque branch with supplementFromIDToken and the tests in pkg/authz/authorizers/cedar/core_test.go around line 1880, including opaque_fallback_does_not_pass_as_upstream. Define the new hybrid claim-source label and decide how other request-token claims and documentation should be handled. Done means the pinned provider's id_token supplies profile claims without replacing the request token's sub, existing fail-closed behavior remains, and tests cover the fallback.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
authorization
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.