feat(authserver): accept ID-JAG assertions with a bound JWT-bearer handler - #6677
Conversation
|
/retest |
The embedded authorization server's RFC 7523 JWT-bearer grant only accepted plain assertions: JWTBearerHandler deliberately declines any assertion whose JOSE typ is oauth-id-jag+jwt, reserving it for a bound handler that did not exist. An IdP-minted Identity Assertion Authorization Grant (draft-ietf-oauth-identity-assertion-authz-grant, the inbound half of Cross App Access) therefore could not be redeemed at the token endpoint at all - no handler claimed the grant and fosite returned its generic invalid_request. Add IDJAGHandler, claiming exactly the assertions the plain handler declines. Bound means client authentication is never skipped: fosite must resolve the caller before the handler runs, and the handler enforces the draft's client_id continuity - the assertion's client_id claim must name that client. jti is required and consumed under its own replay purpose. The issued token keeps the plain handler's subject form and no-upstream-session marker, carries the real redeeming client instead of a synthetic one, and copies the assertion's act claim through so the acting agent stays auditable after redemption. Both handlers are registered from the same per-issuer policy whenever the grant is enabled; no new configuration surface. The shared consume/lifetime/issuance plumbing and the factory core are extracted so the two handlers cannot drift. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Aron <aron@muonspace.com>
8abb307 to
9e8d925
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #6677 +/- ##
==========================================
+ Coverage 79.12% 79.17% +0.05%
==========================================
Files 785 786 +1
Lines 78458 78538 +80
==========================================
+ Hits 62077 62182 +105
+ Misses 16376 16351 -25
Partials 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
jhrozek
left a comment
There was a problem hiding this comment.
went through this in detail — client binding logic checks out (no empty-equals-empty pass on client_id continuity), jti/replay namespacing is separate from the plain handler, and the act claim stays a passenger the whole way through, never touching sub/client_id. two-handler typ split is safe against fosite's actual dispatch loop. CI's all green. the open questions in #6676 have reasonable answers, replying there separately, none of them block this.
Summary
The embedded auth server's RFC 7523 JWT-bearer grant is unbound-only:
JWTBearerHandlerdeliberately declines any assertion whose JOSEtypisoauth-id-jag+jwt, leaving a recognized ID-JAG for "a future bound handler" that didn't exist. So ToolHive can request ID-JAGs (the vMCP XAA outgoing strategy, #5218) but a ToolHive-fronted MCP server can't be the resource side of Cross App Access: when an IdP like Okta mints an ID-JAG for an agent and the agent redeems it at our token endpoint, no handler claims the grant and fosite returns its genericinvalid_request. Reproduced end to end against a live Okta org (details in the linked issue).This PR adds the bound handler:
IDJAGHandlerclaims exactly the assertions the plain handler declines (typ: oauth-id-jag+jwt); one grant type, two handlers, mutually exclusive per request by construction.CanSkipClientAuthis unconditionally false), and the handler enforces the draft's §4.4.1 binding: the JAG'sclient_idclaim must name the fosite-resolved client. Confidential clients authenticate fully; a public client is identified byclient_id, which suffices because the assertion itself is the primary credential (single-usejti,maxAssertionAge-capped, audience-pinned).jtiis required (the draft requires it; no assertion-hash fallback), consumed under its own replay purpose (id-jag).<issuer>#<subject>) andNoUpstreamSessionClaimKey, but carries the real redeeming client (no synthetic client) and copies the JAG'sactclaim through so the acting agent stays auditable after redemption.MultiIssuerTokenValidator,validateJWTBearerPolicy(subject bindings, exactly-one-resource, max age), and the existing per-issuerinbound_grants.jwt_bearer.issuer_policiesconfig — no new config surface, no CRD change. Registering the grant now registers both handlers.grant_typesmetadata is deliberately not consulted: with open DCR, self-asserted metadata authorizes nothing; the per-issuer policy plus the IdP-administrator-configuredclient_idbinding are the authorization.Refactoring along the way: the plain handler's consume/lifetime/issuance plumbing is extracted into shared helpers (
consumeAssertion,assertionBoundedLifetime,populateAccessTokenResponse) and the factory core intojwtBearerGrantFactory, so the two handlers cannot drift.IDJAGHandlerholds its core as a named field, not an embedded type, to avoid the promoted-method dispatch trap (a promotedPopulateTokenEndpointResponsewould gate on the core's matcher and reject every ID-JAG).Fixes #6676
Type of change
Test plan
task test)task test-e2e)task lint-fix)New unit coverage: typ-based matcher split and unconditional client-auth requirement, §4.4.1 binding rejections (missing/mismatched
client_id, unresolved client fails closed, none of which consume the assertion), required-jti, full issuance (replay purpose + key, subject form,actpropagation, real client retained, lifetime capped by assertion expiry), replay/storage-outage error mapping, and the Populate gate against the promoted-method trap.Does this introduce a user-facing change?
Yes: an authorization server with the JWT-bearer grant enabled now also accepts ID-JAG assertions (
typ: oauth-id-jag+jwt) under the same per-issuer policies, from the client each assertion names. Previously these were rejected. Existing plain-assertion behavior is unchanged.Special notes for reviewers
aud_tenant/sub_profileare validated as opaque extras and not propagated into the issued token; onlyactis.Generated with Claude Code