Skip to content

feat(authserver): accept ID-JAG assertions with a bound JWT-bearer handler - #6677

Merged
jhrozek merged 1 commit into
stacklok:mainfrom
aron-muon:bound-id-jag-handler
Sep 17, 2026
Merged

jhrozek merged 1 commit into
stacklok:mainfrom
aron-muon:bound-id-jag-handler

Conversation

@aron-muon

Copy link
Copy Markdown
Contributor

Summary

The embedded auth server's RFC 7523 JWT-bearer grant is unbound-only: JWTBearerHandler deliberately declines any assertion whose JOSE typ is oauth-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 generic invalid_request. Reproduced end to end against a live Okta org (details in the linked issue).

This PR adds the bound handler:

  • IDJAGHandler claims exactly the assertions the plain handler declines (typ: oauth-id-jag+jwt); one grant type, two handlers, mutually exclusive per request by construction.
  • Bound means client authentication is never skipped (CanSkipClientAuth is unconditionally false), and the handler enforces the draft's §4.4.1 binding: the JAG's client_id claim must name the fosite-resolved client. Confidential clients authenticate fully; a public client is identified by client_id, which suffices because the assertion itself is the primary credential (single-use jti, maxAssertionAge-capped, audience-pinned).
  • jti is required (the draft requires it; no assertion-hash fallback), consumed under its own replay purpose (id-jag).
  • The issued token keeps the plain handler's subject form (<issuer>#<subject>) and NoUpstreamSessionClaimKey, but carries the real redeeming client (no synthetic client) and copies the JAG's act claim through so the acting agent stays auditable after redemption.
  • Everything else is reused: the shared MultiIssuerTokenValidator, validateJWTBearerPolicy (subject bindings, exactly-one-resource, max age), and the existing per-issuer inbound_grants.jwt_bearer.issuer_policies config — no new config surface, no CRD change. Registering the grant now registers both handlers.
  • The redeeming client's registered grant_types metadata is deliberately not consulted: with open DCR, self-asserted metadata authorizes nothing; the per-issuer policy plus the IdP-administrator-configured client_id binding 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 into jwtBearerGrantFactory, so the two handlers cannot drift. IDJAGHandler holds its core as a named field, not an embedded type, to avoid the promoted-method dispatch trap (a promoted PopulateTokenEndpointResponse would gate on the core's matcher and reject every ID-JAG).

Fixes #6676

Type of change

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

Test plan

  • Unit tests (task test)
  • E2E tests (task test-e2e)
  • Linting (task lint-fix)
  • Manual testing (describe below)

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, act propagation, 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

  • Draft §9.1 RECOMMENDS confidential redeeming clients; this accepts identified public clients too, with the reasoning above (and in the issue). If you'd rather gate public clients behind config, that's an easy follow-up knob on the issuer policy.
  • aud_tenant / sub_profile are validated as opaque extras and not propagated into the issued token; only act is.

Generated with Claude Code

@aron-muon

Copy link
Copy Markdown
Contributor Author

/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>
@codecov

codecov Bot commented Sep 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.17204% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.17%. Comparing base (9208d07) to head (9e8d925).

Files with missing lines Patch % Lines
.../authserver/server/tokenexchange/id_jag_handler.go 86.66% 8 Missing ⚠️
...hserver/server/tokenexchange/jwt_bearer_handler.go 92.85% 2 Missing ⚠️
pkg/authserver/server_impl.go 80.00% 1 Missing ⚠️
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.
📢 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 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@jhrozek
jhrozek merged commit 74c659b into stacklok:main Sep 17, 2026
43 checks passed
@github-actions github-actions Bot mentioned this pull request Sep 18, 2026
2 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Embedded auth server: bound JWT-bearer handler for ID-JAG assertions

2 participants