@@ -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.
714807const (
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