Skip to content

Fall back to request-token claims for opaque upstream tokens - #5147

Merged
jhrozek merged 1 commit into
stacklok:mainfrom
cjohnhanson:fix/cedar-opaque-token-fallback
Jun 1, 2026
Merged

Fall back to request-token claims for opaque upstream tokens#5147
jhrozek merged 1 commit into
stacklok:mainfrom
cjohnhanson:fix/cedar-opaque-token-fallback

Conversation

@cjohnhanson

Copy link
Copy Markdown

Summary

  • VirtualMCPServer's incoming Cedar authorizer denied every request when the embedded auth server's upstream provider issues opaque OAuth 2.0 access tokens (Google's ya29.*, GitHub's gho_*). resolveClaims JWT-parsed 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 (per JOSE compact serialization), so fall back to identity.Claims (the request-token claims). The embedded auth server already mirrors the upstream OIDC sub/email/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. The added looksLikeJWT(tokenStr) = strings.Count(tokenStr, ".") == 2 discriminator keeps that boundary explicit.
  • The auto-binding of PrimaryUpstreamProvider added in Fix Cedar upstream-claim evaluation on VirtualMCPServer #5002 (operator side, cmd/thv-operator/pkg/vmcpconfig/converter.go) doesn't expose an opt-out CRD field, so users with opaque-token providers had no in-config workaround. This change unblocks them without changing operator behavior.

Closes #5146

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

  • Unit tests (go test ./pkg/authz/...)
  • go vet ./pkg/authz/...
  • gofmt -l pkg/authz/authorizers/cedar/ (clean)
  • End-to-end verified on a GKE staging cluster: VirtualMCPServer with accounts.google.com upstream, Cedar policy referencing claim_email. Pre-fix: tools/list returns empty with Authorization check failed for tool, skipping per tool. Post-fix: full aggregated tool list returned, single WARN line upstream token is not a JWT; falling back to request-token claims for Cedar evaluation provider=google.

The existing TestParseUpstreamJWTClaims/opaque_token_returns_error row stays unchanged — the helper still returns the same error; only the caller's recovery behavior changes.

API Compatibility

  • This PR does not break the v1beta1 API. No CRD or ConfigOptions schema change.

Changes

File Change
pkg/authz/authorizers/cedar/core.go resolveClaims discriminates looksLikeJWT(tokenStr) before falling back. Opaque (≠3 segments) → fall back to identity.Claims with a WARN. JWT-shaped but unparseable → return the original parse error (deny). Adds the looksLikeJWT helper.
pkg/authz/authorizers/cedar/core_test.go Replaces the upstream_token_opaque_not_parseable row in TestAuthorizeWithJWTClaims_UpstreamProvider with three rows: opaque-fallback-denied (policy mismatch via fallback claims), opaque-fallback-permitted (policy matches via fallback claims), and JWT-shaped-but-malformed-still-errors (security-regression guard for tampered JWTs).

Does this introduce a user-facing change?

Yes — bug-fix only. Operators with Google (or any opaque-access-token) upstream OIDC provider now receive the aggregated tools list instead of an empty one. No CRD or configuration change required. A WARN log surfaces each fallback so operators can observe the path their identity claims take.

Tampered or corrupted upstream JWTs still hard-deny — there is no behavior change for JWT-access-token providers (Okta, Azure AD with JWT access tokens, etc.).

Special notes for reviewers

A complementary operator-side change — only auto-set PrimaryUpstreamProvider when the user hasn't asked for the explicit fallback — would expose the documented escape hatch via a CRD field, since the docstring on PrimaryUpstreamProvider already says "When empty, claims from the ToolHive-issued token are used." That route is more invasive (CRD + types + converter + tests). Happy to PR it separately if you'd prefer the opt-in surface over the implicit fallback in this PR. Either approach unblocks the failing case.

The fallback could also be argued to belong behind a ConfigOptions.AllowOpaqueTokenFallback knob (default false). I left it implicit here because (a) the discriminator preserves the deny for tampered JWTs, which is the security-relevant case, and (b) for opaque-token providers the previous behavior was always-deny — there's no operator who relied on it doing anything useful. Open to adding the gate if you'd prefer the more conservative surface.

jhrozek
jhrozek previously approved these changes May 13, 2026
Comment thread pkg/authz/authorizers/cedar/core.go
@github-actions github-actions Bot added the size/XS Extra small PR: < 100 lines changed label May 13, 2026
@jhrozek

jhrozek commented May 13, 2026

Copy link
Copy Markdown
Contributor

hey @cjohnhanson first of all sorry it took so long to review your PR, it somehow slipped through the review queue. That shouldn't happen!

I've included just one nit inline, let me know if you want to address it if not, I'm fine sending a follow up (feeling a bit bad to ask you to do more work after letting you wait for almost 2 weeks..)

@codecov

codecov Bot commented May 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 55.55556% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.82%. Comparing base (394b2e7) to head (dc40547).
⚠️ Report is 15 commits behind head on main.

Files with missing lines Patch % Lines
pkg/authz/authorizers/cedar/core.go 55.55% 3 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5147      +/-   ##
==========================================
- Coverage   68.83%   68.82%   -0.01%     
==========================================
  Files         627      627              
  Lines       63590    63599       +9     
==========================================
  Hits        43772    43772              
- Misses      16568    16575       +7     
- Partials     3250     3252       +2     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@jhrozek

jhrozek commented May 13, 2026

Copy link
Copy Markdown
Contributor

oh looks like the linter is unhappy as well, this might require another push

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 stacklok#5146

Signed-off-by: Cody J. Hanson <cjohnhanson@users.noreply.github.com>
@cjohnhanson

Copy link
Copy Markdown
Author

@jhrozek no worries, sorry for the delay on coming back to this. Force-pushed with the nit addressed.

@github-actions github-actions Bot added size/XS Extra small PR: < 100 lines changed and removed size/XS Extra small PR: < 100 lines changed labels Jun 1, 2026
@jhrozek
jhrozek merged commit bfde12d into stacklok:main Jun 1, 2026
79 checks passed
JAORMX added a commit that referenced this pull request Jul 27, 2026
With the embedded auth server active, the runner force-injects the first
upstream provider as Cedar's PrimaryUpstreamProvider, so policies are
evaluated against the upstream IdP's access token. Many OIDC providers
assert profile claims only in the id_token and omit them from the access
token (JetBrains Hub is a public example). Those access tokens are
JWT-shaped and parse cleanly, so the opaque-token fallback added in #5147
never applied, and any policy referencing claim_email evaluated a claim
set that could not contain it: tools/list returned empty and tools/call
returned 403 for every user, with no diagnostic. Audit logging reads the
AS-issued token, so it kept reporting the correct user and pointed
investigations away from authorization.

The auth server already mirrors the upstream sub/name/email into the
token it issues, so let those three claims — and only those — fall back
to the request token's values when the upstream access token omits them.
An upstream value always wins where the IdP asserts one. The supplement
stops there on purpose: groups, roles and scopes must remain
upstream-only so a claim on the ToolHive-issued token can never grant
access the upstream did not assert, and the AS token's own protocol
claims (iss, aud, exp, jti, client_id) describe that token rather than
the upstream identity. Nothing is fabricated, so has-guarded policies
still fail closed on claims neither token carries.

Also log once per rate-limit window naming the claims that were
supplemented, the JWT-branch counterpart of the existing opaque-token
warning, so this is diagnosable from logs alone.

Fixes #5916

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JAORMX added a commit that referenced this pull request Jul 27, 2026
With the embedded auth server active, the runner force-injects the first
upstream as Cedar's PrimaryUpstreamProvider, so policies were evaluated
against that provider's access token alone. Many OIDC providers assert
profile claims only in the id_token, so a JWT-shaped access token without
`email` parsed cleanly, never hit the opaque-token fallback from #5147, and
left `principal has claim_email` false for every user: tools/list returned
empty and tools/call 403, with no diagnostic. Audit logging reads the
AS-issued token, so it kept naming the correct user and pointed
investigations away from authorization.

Supplement the missing profile claims from the same provider's stored
id_token. `name` and `email` fall back to it when the access token omits
them; access-token values always win. Provenance is preserved by
construction, so `principal has claim_email` still means "this upstream
asserted an email".

The ToolHive-issued token is deliberately not a claim source here. In a
multi-upstream chain its mirrored name/email always belong to the FIRST
configured upstream (validateChain pins chain[0] to upstreams[0], and only
the first leg resolves identity from its provider), while
primaryUpstreamProvider may name any upstream — so using it would attribute
one IdP's email to another.

`sub` is never supplemented: it becomes the Cedar principal entity ID, where
Cedar offers no `has`-style guard, so a missing access-token `sub` fails
closed with ErrMissingPrincipal rather than having a principal chosen for it.

The id_token is read without checking `exp`, deliberately. It is a record of
what the upstream asserted at login, not a presented credential; enforcement
of that contract belongs at the RFC 8693 consumer. Enforcing it here would
let a policy permit early in a session and silently deny later, since
sessions outlive id_tokens by days.

Two rate-limited warnings make the condition diagnosable from logs alone,
distinguishing "used the id_token" from "no id_token stored, will deny".
Also makes captureSlogWarn concurrency-safe: it installs a capturing handler
via the process-global slog.SetDefault, which the new warning turned into a
reproducible data race under -race.

Fixes #5916
JAORMX added a commit that referenced this pull request Jul 28, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XS Extra small PR: < 100 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

VirtualMCPServer Cedar authorization rejects all requests when upstream OIDC provider issues opaque access tokens

2 participants