From 01ebdf415e0ec8f53f378847ae3ec39e6566996a Mon Sep 17 00:00:00 2001 From: "Cody J. Hanson" Date: Thu, 30 Apr 2026 23:10:22 +0000 Subject: [PATCH] Fall back to request-token claims for opaque upstream tokens VirtualMCPServer (Cedar incoming authz) denied every request when the embedded auth servers upstream provider issues opaque OAuth 2.0 access tokens (Googles ya29.*, GitHubs gho_*). resolveClaims tried to JWT-parse the upstream token unconditionally and returned the parse error verbatim, so every authorization check failed and the gateway skipped every tool. Discriminate by token shape: if the upstream token is not three dot- separated segments it cannot be a JWT, so fall back to identity.Claims (the request-token claims). The embedded auth server already mirrors the upstream OIDC sub, email and name into its issued AS token (see pkg/authserver/server/session/session.go), so policies referencing standard OIDC claims continue to evaluate correctly. JWT-shaped tokens (three segments) that fail to parse still return the error: a tampered or corrupted upstream JWT must not silently degrade to fallback claims. Closes #5146 Signed-off-by: Cody J. Hanson --- pkg/authz/authorizers/cedar/core.go | 27 ++++++++++++++++ pkg/authz/authorizers/cedar/core_test.go | 41 +++++++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/pkg/authz/authorizers/cedar/core.go b/pkg/authz/authorizers/cedar/core.go index a0de7a2c2c..c22d7e7a64 100644 --- a/pkg/authz/authorizers/cedar/core.go +++ b/pkg/authz/authorizers/cedar/core.go @@ -492,6 +492,26 @@ func (a *Authorizer) resolveClaims(identity *auth.Identity) (jwt.MapClaims, erro } parsedClaims, err := parseUpstreamJWTClaims(upstreamToken) if err != nil { + // Distinguish "not JWT-shaped" (opaque OAuth 2.0 access token — + // Google's ya29.*, GitHub's gho_*, etc.) from "JWT-shaped but + // malformed/tampered" (a JWT with three segments that fails to + // parse). Only fall back for the former; preserve the deny for + // the latter so a tampered upstream JWT cannot bypass policy. + // + // The embedded auth server already mirrors the upstream OIDC + // sub/email/name claims into its issued AS token (see + // pkg/authserver/server/session/session.go). For opaque-token + // providers, falling back to identity.Claims preserves identity + // for policies referencing standard OIDC claims; policies that + // reference upstream-only claims (groups, hd, custom namespaced + // claims) will see those attributes as absent and must be + // authored defensively (`has(claim_groups) && ...`). + if !looksLikeJWT(upstreamToken) { + slog.Warn("upstream token is not a JWT; falling back to request-token claims for Cedar evaluation", + "provider", a.primaryUpstreamProvider) + a.logClaimKeys("token-fallback", jwt.MapClaims(identity.Claims)) + return jwt.MapClaims(identity.Claims), nil + } return nil, fmt.Errorf("failed to parse upstream token for provider %q: %w", a.primaryUpstreamProvider, err) } @@ -504,6 +524,13 @@ func (a *Authorizer) resolveClaims(identity *auth.Identity) (jwt.MapClaims, erro return claims, nil } +// looksLikeJWT returns true when the token has the three-segment shape of a +// JOSE-compact-serialized JWT (`header.payload.signature`). It does not +// validate the contents; the parser handles that. +func looksLikeJWT(tokenStr string) bool { + return strings.Count(tokenStr, ".") == 2 +} + // logClaimKeys emits a rate-limited DEBUG log listing the JWT claim keys // available for Cedar policy evaluation. func (a *Authorizer) logClaimKeys(source string, claims jwt.MapClaims) { diff --git a/pkg/authz/authorizers/cedar/core_test.go b/pkg/authz/authorizers/cedar/core_test.go index c53d6b430e..79f6036d66 100644 --- a/pkg/authz/authorizers/cedar/core_test.go +++ b/pkg/authz/authorizers/cedar/core_test.go @@ -1296,7 +1296,7 @@ func TestAuthorizeWithJWTClaims_UpstreamProvider(t *testing.T) { errContains: "upstream token for provider", }, { - name: "upstream_token_opaque_not_parseable", + name: "upstream_token_opaque_falls_back_to_request_claims_denied", identity: &auth.Identity{ PrincipalInfo: auth.PrincipalInfo{ Subject: "thv-user", @@ -1306,6 +1306,45 @@ func TestAuthorizeWithJWTClaims_UpstreamProvider(t *testing.T) { providerName: "opaque-token-cannot-be-parsed", }, }, + // Opaque upstream tokens (Google's ya29.*, GitHub's gho_*, etc.) + // trigger the fallback to identity.Claims. Here the request-token + // sub does not match the policy, so authorization is correctly + // denied based on policy evaluation rather than a parse-time error. + wantAuthorize: false, + }, + { + name: "upstream_token_opaque_falls_back_to_request_claims_permitted", + identity: &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{ + Subject: "upstream-user", + Claims: map[string]any{"sub": "upstream-user"}, + }, + UpstreamTokens: map[string]string{ + providerName: "opaque-token-cannot-be-parsed", + }, + }, + // When the upstream token is not a JWT, Cedar evaluates against + // the request-token claims. The embedded auth server already + // mirrors the upstream OIDC sub/email/name into its issued token, + // so a policy referencing claim_sub still matches the user. + wantAuthorize: true, + }, + { + name: "upstream_token_jwt_shaped_but_malformed_still_errors", + identity: &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{ + Subject: "thv-user", + Claims: map[string]any{"sub": "thv-user"}, + }, + UpstreamTokens: map[string]string{ + // Three-segment shape (looks like a JWT) but the segments are + // not valid base64-encoded JSON — i.e. a tampered or + // corrupted JWT. The fallback path MUST NOT trigger here: + // silently degrading a tampered upstream JWT to fallback + // claims would be a security regression. + providerName: "not-base64.not-base64.not-base64", + }, + }, wantErr: true, errContains: "failed to parse upstream token", },