Skip to content

Commit 66323f8

Browse files
JAORMXclaude
andcommitted
Source Cedar claims from id_token on opaque path
With the embedded auth server active, Cedar resolved claims from two sources depending on an accident of the pinned upstream's access token format. A JWT access token gave claims from that upstream, so the Cedar principal was the upstream subject. An opaque one (Google's ya29.*, GitHub's gho_*) hit the #5147 fallback and gave the claims of the token ToolHive itself issued, so the principal became ToolHive's internal user UUID and `forbid(principal == Client::"okta|alice", ...)` silently stopped matching. In a multi-upstream chain the same fallback attributed the FIRST configured upstream's profile to the pinned provider, since that is whose name/email the auth server mirrors. Read the pinned provider's own id_token on that branch instead. It supplies the principal `sub` plus the profileClaimsFromIDToken allowlist that already governs the JWT path, so both paths now admit the same claim names from the same provider and session, and widening the allowlist widens them together. Claims outside it stay out: an id_token's iss/aud/nonce describe the token rather than the user, and admitting more here than on the JWT path would recreate the inconsistency being removed. `sub` is not being supplemented in the sense the JWT path forbids: an opaque token asserts nothing, so there is no access-token `sub` to override, and an id_token that omits `sub` still fails closed with ErrMissingPrincipal rather than borrowing the auth server's subject. `exp` is not enforced, as for the existing supplement — the token records what the upstream asserted at login, and rejecting it once expired would flip a policy from permit to deny mid-session. A pure OAuth 2.0 upstream never asked for the `openid` scope has no id_token at all (GitHub OAuth apps are the common case) and no other record of that provider's subject exists in the identity. For that configuration alone the ToolHive-issued token's claims are still used, which is what shipped before: failing closed would deny every request on those deployments with nothing an operator could configure to recover, so the weaker attribution is the lesser harm. A rate-limited WARN names the provider and the consequence. Fixes #6048 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n
1 parent 33023da commit 66323f8

4 files changed

Lines changed: 464 additions & 55 deletions

File tree

docs/operator/virtualmcpserver-kubernetes-guide.md

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -671,28 +671,55 @@ days, expiring it out of policy evaluation would let the same user be permitted
671671
early in a session and denied later, with no configuration change.
672672

673673
The token the client presented — the one ToolHive's auth server issued — is
674-
never a claim source here, even though it mirrors a `name` and `email`. In a
675-
multi-upstream chain those mirrored values come from the **first** configured
676-
upstream (the identity provider), which need not be the provider
677-
`primaryUpstreamProvider` names, so using them could attribute one IdP's email to
678-
another.
674+
never a claim source here, even though it mirrors a `name` and `email`, with the
675+
single exception noted below. In a multi-upstream chain those mirrored values come
676+
from the **first** configured upstream (the identity provider), which need not be
677+
the provider `primaryUpstreamProvider` names, so using them could attribute one
678+
IdP's email to another.
679+
680+
**Opaque upstream access tokens**: some providers (Google's `ya29.…`, GitHub's
681+
`gho_…`) issue access tokens that are not JWTs at all, so there are no claims in
682+
them to read. For those, that same provider's `id_token` is the claim source
683+
instead: it supplies the principal (`sub`) and the same `name`/`email` profile
684+
claims, so a rule keyed on `Client::"<upstream-subject>"` matches the upstream
685+
identity here exactly as it does for a JWT access token. Claims the `id_token`
686+
carries beyond those — `iss`, `aud`, `nonce`, groups, or provider-specific ones
687+
like Google's `hd` — are not admitted, the same restriction that applies when a
688+
JWT access token is supplemented.
689+
690+
Earlier releases evaluated the ToolHive-issued token on this path instead, so a
691+
policy written against that token's values needs re-checking against the pinned
692+
provider's `id_token`: the principal is now the upstream subject rather than
693+
ToolHive's internal user ID, and in a multi-upstream chain `claim_email` now names
694+
the pinned provider's email rather than the first configured upstream's.
679695

680696
An OAuth 2.0 upstream that was never asked for the `openid` scope has no stored
681-
`id_token`, so nothing is available to fall back to and the claim stays absent.
682-
The proxy says so once per 30s:
697+
`id_token`. With a JWT access token, nothing is available to fall back to and the
698+
profile claim stays absent. With an opaque access token there is no upstream claim
699+
source at all, and only in that case does Cedar evaluate the claims of the token
700+
the client presented — so the principal is ToolHive's internal user ID rather than
701+
the upstream subject, and a rule keyed on the upstream subject will not match.
702+
Requesting the `openid` scope for that upstream is what fixes it. Either way the
703+
proxy says so once per 30s:
683704

684705
```
685706
WARN no upstream ID token stored for provider; policies referencing profile
686707
claims the access token omits will deny provider=okta
708+
709+
WARN no usable upstream ID token for provider with an opaque access token;
710+
falling back to the claims of the ToolHive-issued token, so the Cedar
711+
principal is ToolHive's internal user ID rather than the upstream subject
712+
provider=github
687713
```
688714

689-
Every other claim is upstream-access-token-only. In particular, group, role and
690-
scope claims are not substituted from anywhere, so a policy such as `principal in
691-
THVGroup::"platform-eng"` only ever matches groups the upstream access token
692-
asserts. The same holds for the principal: `sub` is never substituted, so a rule
693-
keyed on `Client::"<upstream-subject>"` keeps matching the upstream identity, and
694-
an access token with no `sub` is rejected outright rather than having a principal
695-
chosen for it.
715+
Every other claim comes from the upstream access token or not at all. In
716+
particular, group, role and scope claims are not substituted from anywhere, so a
717+
policy such as `principal in THVGroup::"platform-eng"` only ever matches groups the
718+
pinned upstream asserts. The principal is always that upstream's subject too: a JWT
719+
access token's `sub` is never substituted, so a JWT that carries no `sub` is
720+
rejected outright rather than having a principal chosen for it, and an opaque access
721+
token takes the subject from the `id_token` of the same provider and session — never
722+
from the token the client presented.
696723

697724
If a policy references a claim that neither the access token nor the `id_token`
698725
carries, `principal has claim_x` is false and the policy denies — author domain

pkg/authz/authorizers/cedar/core.go

Lines changed: 121 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,8 @@ type Authorizer struct {
172172
mu sync.RWMutex
173173
// primaryUpstreamProvider names the upstream IDP provider whose access token
174174
// is the source of JWT claims for Cedar evaluation, aside from the narrow
175-
// user-profile supplement described in resolveClaims.
175+
// user-profile supplement and the opaque-access-token case described in
176+
// resolveClaims — both of which read the same provider's id_token.
176177
// When empty, claims from the token on the original client request are used,
177178
// which may be a ToolHive-issued token or any other bearer token.
178179
primaryUpstreamProvider string
@@ -197,9 +198,10 @@ type Authorizer struct {
197198
// the window, hiding the claim-key dump on exactly the deployments that need it.
198199
supplementLog *syncutil.AtMost
199200
// missingIDTokenLog rate-limits the warning that the primary provider has no
200-
// usable stored id_token, so profile claims cannot be supplemented at all.
201-
// Separate from supplementLog because the two are mutually exclusive per
202-
// request and describe opposite conditions.
201+
// usable stored id_token — meaning either that profile claims cannot be
202+
// supplemented (JWT access token) or that there is no upstream claim source at
203+
// all (opaque access token). Separate from supplementLog because the two are
204+
// mutually exclusive per request and describe opposite conditions.
203205
missingIDTokenLog *syncutil.AtMost
204206
// multiValuedClaims lists JWT claim names normalized to a canonical unpadded
205207
// space-delimited string, plus a companion Cedar Set, before Cedar evaluation.
@@ -223,10 +225,13 @@ type ConfigOptions struct {
223225
// The profile claims in profileClaimsFromIDToken fall back to the SAME
224226
// provider's id_token when its access token omits them (many OIDC providers
225227
// assert `email` only in the id_token), so `principal has claim_email` keeps
226-
// meaning "this upstream asserted an email". Every other claim, including all
227-
// group/role/scope claims, comes from the upstream access token or not at all,
228-
// and the ToolHive-issued token the client presented is never a claim source on
229-
// this path. See resolveClaims for the full contract.
228+
// meaning "this upstream asserted an email". An opaque access token has no
229+
// claims at all, so that provider's id_token supplies the principal and those
230+
// same profile claims instead. Every other claim, including all group/role/scope
231+
// claims, comes from the upstream access token or not at all, and the
232+
// ToolHive-issued token the client presented is a claim source only for an
233+
// opaque-access-token provider with no stored id_token, which has no upstream
234+
// claim source whatsoever. See resolveClaims for the full contract.
230235
PrimaryUpstreamProvider string `json:"primary_upstream_provider,omitempty" yaml:"primary_upstream_provider,omitempty"`
231236

232237
// GroupClaimName is the JWT claim key that contains group membership for the
@@ -578,7 +583,32 @@ func (a *Authorizer) IsAuthorized(
578583
// The ToolHive-issued token the client presented is deliberately NOT a claim
579584
// source here, even though it mirrors a name/email — in a multi-upstream chain
580585
// those belong to the first configured upstream, which need not be the pinned
581-
// provider, so using them could attribute one IdP's email to another.
586+
// provider, so using them could attribute one IdP's email to another. The single
587+
// exception is the last-resort case at the end of this comment, where the pinned
588+
// provider offers no claim source at all.
589+
//
590+
// When the pinned provider's access token is OPAQUE rather than JWT-shaped
591+
// (Google's ya29.*, GitHub's gho_*) it carries no claims to read, so the same
592+
// provider's id_token stands in as the claim carrier: it supplies the Cedar
593+
// principal (`sub`) plus the profileClaimsFromIDToken it asserts, and nothing
594+
// else. This keeps the principal on the upstream subject and the profile claims
595+
// attributed to the pinned provider — what the two branches used to disagree
596+
// about (#6048). `sub` is not "supplemented" here in the sense the bullets below
597+
// forbid: it is the subject of the only claim source there is, for the same
598+
// provider and session, and no access-token `sub` exists to be overridden.
599+
//
600+
// A pinned provider whose access token is opaque AND that has no usable stored
601+
// id_token has no upstream claim source at all. That is a pure OAuth 2.0 upstream
602+
// (pkg/authserver/upstream/oauth2.go) never asked for the `openid` scope, whose
603+
// identity is resolved from a userinfo endpoint and never lands in an id_token;
604+
// an OIDC upstream always has one. For that configuration ALONE the claims of the
605+
// token on the original client request are used, exactly as they were before
606+
// #6048 — so the Cedar principal is ToolHive's internal user ID and the profile
607+
// claims are the auth server's mirror of the FIRST configured upstream. Failing
608+
// closed instead would deny every request on those deployments with nothing an
609+
// operator could configure to recover, which is the deny-all trap #5916 was; the
610+
// weaker attribution is the lesser harm, and a rate-limited WARN names the
611+
// provider so the condition is diagnosable from logs alone.
582612
//
583613
// The supplement is restricted to profileClaimsFromIDToken rather than merging
584614
// the two token bodies. Read this before widening it:
@@ -594,7 +624,9 @@ func (a *Authorizer) IsAuthorized(
594624
// - `sub` is excluded because it becomes the Cedar principal entity ID rather
595625
// than an attribute, and Cedar has no `has`-style guard in the principal
596626
// position. An access token without `sub` violates RFC 9068; failing closed
597-
// with ErrMissingPrincipal beats silently choosing a principal.
627+
// with ErrMissingPrincipal beats silently choosing a principal. An opaque
628+
// access token is a different case, not an exception to this one: it asserts
629+
// nothing, so there is no access-token `sub` to override.
598630
//
599631
// An access-token value always wins, so a claim the access token does assert can
600632
// never be shadowed. A claim absent from both tokens stays absent, so `has`-guarded
@@ -634,14 +666,7 @@ func (a *Authorizer) resolveClaims(identity *auth.Identity) (jwt.MapClaims, erro
634666
// namespaced claims) see those attributes as absent on this branch and
635667
// must be authored defensively (`principal has claim_groups && ...`).
636668
if !looksLikeJWT(upstreamToken) {
637-
// The Warn shares claimKeyLog with logClaimKeys below so a busy
638-
// Google/GitHub deployment does not emit one line per tool call.
639-
a.claimKeyLog.Do(func() {
640-
slog.Warn("upstream token is not a JWT; falling back to request-token claims for Cedar evaluation",
641-
"provider", a.primaryUpstreamProvider)
642-
})
643-
a.logClaimKeys("token-fallback", requestClaims)
644-
return requestClaims, nil
669+
return a.claimsForOpaqueUpstreamToken(identity, requestClaims), nil
645670
}
646671
return nil, fmt.Errorf("failed to parse upstream token for provider %q: %w",
647672
a.primaryUpstreamProvider, err)
@@ -652,6 +677,69 @@ func (a *Authorizer) resolveClaims(identity *auth.Identity) (jwt.MapClaims, erro
652677
return merged, nil
653678
}
654679

680+
// errNoUpstreamIDToken reports that no id_token is stored for the pinned provider,
681+
// so that "nothing to read" and "stored but unparsable" reach one log line.
682+
var errNoUpstreamIDToken = errors.New("no ID token stored for provider")
683+
684+
// claimsForOpaqueUpstreamToken returns the claim set to evaluate when the pinned
685+
// provider's access token is opaque, so it carries no claims of its own.
686+
//
687+
// The same provider's id_token stands in as the claim carrier. Only its `sub` and
688+
// the profileClaimsFromIDToken it asserts are admitted: `sub` because the Cedar
689+
// principal has to come from somewhere and this is the pinned provider's own
690+
// subject, and the profile claims through the same allowlist that governs the
691+
// JWT-access-token path, so both paths admit exactly the same claim names and
692+
// widening the allowlist (see #6049 for group claims) widens them together. The
693+
// id_token's remaining claims stay out: an `iss`/`aud`/`nonce`/`at_hash` describes
694+
// the token rather than the user, and admitting the rest here but not on the JWT
695+
// path would recreate the very inconsistency this resolves.
696+
//
697+
// `exp` is not checked, as elsewhere in this file — see Identity.UpstreamIDTokens.
698+
//
699+
// requestClaims (the ToolHive-issued token's claims) is the last resort for a
700+
// provider with no usable id_token, which is a pure OAuth 2.0 upstream; see
701+
// resolveClaims for why that keeps working rather than failing closed.
702+
func (a *Authorizer) claimsForOpaqueUpstreamToken(identity *auth.Identity, requestClaims jwt.MapClaims) jwt.MapClaims {
703+
idToken := identity.UpstreamIDTokens[a.primaryUpstreamProvider] // nil map safe in Go
704+
705+
idTokenClaims, err := func() (jwt.MapClaims, error) {
706+
if idToken == "" {
707+
return nil, errNoUpstreamIDToken
708+
}
709+
return parseUpstreamJWTClaims(idToken)
710+
}()
711+
if err != nil {
712+
// Shares missingIDTokenLog with supplementFromIDToken: both report that the
713+
// provider has no usable id_token, and a request takes only one of the two
714+
// paths, so neither starves the other.
715+
a.missingIDTokenLog.Do(func() {
716+
slog.Warn("no usable upstream ID token for provider with an opaque access token; "+
717+
"falling back to the claims of the ToolHive-issued token, so the Cedar principal is "+
718+
"ToolHive's internal user ID rather than the upstream subject",
719+
"provider", a.primaryUpstreamProvider,
720+
"error", err)
721+
})
722+
a.logClaimKeys("token-fallback", requestClaims)
723+
return requestClaims
724+
}
725+
726+
claims := make(jwt.MapClaims, len(profileClaimsFromIDToken)+1)
727+
// An id_token without `sub` violates OIDC Core 1.0 §2, and leaving the key
728+
// absent makes AuthorizeWithJWTClaims fail closed with ErrMissingPrincipal
729+
// rather than pick a principal from the ToolHive-issued token.
730+
if sub, ok := idTokenClaims[oidcSubjectClaim]; ok {
731+
claims[oidcSubjectClaim] = sub
732+
}
733+
for _, name := range profileClaimsFromIDToken {
734+
if v, ok := idTokenClaims[name]; ok {
735+
claims[name] = v
736+
}
737+
}
738+
739+
a.logClaimKeys("upstream-id-token", claims)
740+
return claims
741+
}
742+
655743
// supplementFromIDToken returns upstreamClaims with the profileClaimsFromIDToken
656744
// it lacks filled in from the primary provider's stored id_token, logging what it
657745
// did. upstreamClaims is not mutated.
@@ -704,16 +792,22 @@ func (a *Authorizer) supplementFromIDToken(identity *auth.Identity, upstreamClai
704792
return merged
705793
}
706794

707-
// OIDC Core 1.0 §5.1 standard claim names, declared here rather than borrowed
708-
// from another package. What this code reads is an upstream provider's id_token,
709-
// so these are wire names fixed by the OIDC spec — not names ToolHive chooses.
710-
// Anchoring them to a ToolHive constant would invert the dependency: renaming that
711-
// constant would silently change which claim Cedar reads out of a third party's
712-
// token. Two literals also do not justify pulling an authorization-server
713-
// dependency tree into the authorizer.
795+
// OIDC Core 1.0 claim names — `sub` from §2, the profile claims from §5.1 —
796+
// declared here rather than borrowed from another package. What this code reads is
797+
// an upstream provider's id_token, so these are wire names fixed by the OIDC spec,
798+
// not names ToolHive chooses. Anchoring them to a ToolHive constant would invert
799+
// the dependency: renaming that constant would silently change which claim Cedar
800+
// reads out of a third party's token. Three literals also do not justify pulling
801+
// an authorization-server dependency tree into the authorizer.
802+
//
803+
// oidcSubjectClaim is deliberately NOT in profileClaimsFromIDToken: it is never
804+
// supplemented into a claim set an access token already produced. It is read from
805+
// the id_token only when the access token is opaque and the id_token is therefore
806+
// the whole claim source — see claimsForOpaqueUpstreamToken.
714807
const (
715-
oidcNameClaim = "name"
716-
oidcEmailClaim = "email"
808+
oidcSubjectClaim = "sub"
809+
oidcNameClaim = "name"
810+
oidcEmailClaim = "email"
717811
)
718812

719813
// profileClaimsFromIDToken are the OIDC Core §5.1 profile claims that may be read

0 commit comments

Comments
 (0)