Skip to content

Gate ID-JAG by issuer assertion types - #6678

Open
jhrozek wants to merge 2 commits into
mainfrom
worktree-idjag-issuer-optin
Open

jhrozek wants to merge 2 commits into
mainfrom
worktree-idjag-issuer-optin

Conversation

@jhrozek

@jhrozek jhrozek commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Trusted issuers configured for RFC 7523 previously also accepted ID-JAG assertions, expanding the issuer trust decision to cross-app-access delegation without an explicit opt-in.
  • Add per-issuer acceptedAssertionTypes selection for plain jwt_bearer, id_jag, or both; default omitted values to plain JWT-bearer for backwards compatibility.
  • Gate plain and ID-JAG handlers independently, validate ID-JAG audience configuration against the local authorization-server issuer, reject unknown runtime assertion types, and document the shared-binding consequence when both forms are selected.

Type of change

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

Test plan

  • Unit tests (task test) — attempted; the suite reaches tests but an unrelated Unix-socket test fails because the harness temporary path exceeds the platform socket-path limit (pkg/server/discovery/health_test.go:174).
  • E2E tests (task test-e2e)
  • Linting (task lint-fix)
  • Manual testing — ran task operator-manifests, task crdref-gen, and git diff --check; regenerated CRDs and API reference documentation successfully.

API Compatibility

  • This PR does not break the v1beta1 API, OR the api-break-allowed label is applied and the migration guidance is described above.

Changes

File Change
cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go Adds assertion-type CRD schema and documentation.
cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go Regenerates deep-copy support.
cmd/thv-operator/pkg/controllerutil/authserver.go Projects assertion types into runtime configuration.
cmd/thv-operator/pkg/controllerutil/authserver_inbound_grants_test.go Covers canonical configuration projection.
cmd/thv-operator/pkg/controllerutil/authserver_test.go Covers legacy configuration projection.
deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml Regenerates external-auth CRD schema.
deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml Regenerates VirtualMCPServer embedded-auth schema.
deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml Regenerates Helm-wrapped external-auth CRD.
deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml Regenerates Helm-wrapped VirtualMCPServer CRD.
docs/arch/17-token-exchange-delegation.md Documents assertion-type trust and shared-binding behavior.
docs/operator/crd-api.md Regenerates CRD API reference.
docs/server/docs.go Regenerates server OpenAPI documentation.
docs/server/swagger.json Regenerates server OpenAPI contract.
docs/server/swagger.yaml Regenerates server OpenAPI contract.
pkg/authserver/inbound_grants.go Carries assertion types through canonical inbound-grant normalization.
pkg/authserver/inbound_grants_test.go Covers normalized assertion-type configuration.
pkg/authserver/server/tokenexchange/id_jag_handler.go Filters ID-JAG issuer policies and updates factory documentation.
pkg/authserver/server/tokenexchange/id_jag_handler_test.go Covers ID-JAG issuer filtering and bound redemption.
pkg/authserver/server/tokenexchange/jwt_bearer_handler.go Filters plain-assertion issuer policies.
pkg/authserver/server/tokenexchange/multi_issuer_validator.go Defines assertion types and validates runtime ID-JAG configuration.
pkg/authserver/server/tokenexchange/multi_issuer_validator_test.go Covers assertion-type and ID-JAG audience validation.
pkg/authserver/server_impl.go Registers assertion handlers only when an issuer opts into the matching type.
pkg/authserver/server_test.go Covers handler-factory gating.
sdk/go/client/oas_json_gen.go Regenerates SDK JSON types.
sdk/go/client/oas_schemas_gen.go Regenerates SDK schemas.
sdk/go/client/oas_validators_gen.go Regenerates SDK validation.
sdk/go/openapi.json Regenerates SDK OpenAPI contract.
sdk/go/openapi.yaml Regenerates SDK OpenAPI contract.

Does this introduce a user-facing change?

Yes. Operators can select which assertion forms each trusted issuer accepts. Existing omitted configuration continues to accept only plain RFC 7523 JWT-bearer assertions; accepting ID-JAG now requires explicit id_jag selection and an audience entry for this authorization server's issuer.

Special notes for reviewers

When an issuer accepts both assertion types, every configured subject/resource binding deliberately authorizes both forms. In particular, a subject added for ID-JAG is also eligible for credential-free plain JWT-bearer redemption; configure both only when that authority is intended.

Generated with Claude Code

A trusted issuer configured for the JWT-bearer grant previously accepted both plain RFC 7523 assertions and ID-JAG cross-app-access assertions. That made trusting an issuer for service-account assertions also trust it as an IdP for a different resource owner without an explicit decision.

Add AcceptedAssertionTypes (jwt_bearer|id_jag) to JWTBearerGrantConfig and use it to filter each handler's issuer policies independently. The enum list preserves the three intended choices: plain assertions only, ID-JAG only, or both. Nil and empty runtime values default to jwt_bearer so standalone and hand-built configurations preserve their existing behavior; invalid runtime values are rejected.

ID-JAG policies must list this authorization server's issuer in AcceptedAudiences, matching the ID-JAG audience requirement and failing invalid configurations at startup. When both assertion types are selected, they intentionally share subject bindings, so the CRD and runtime documentation make clear that a binding also authorizes credential-free plain JWT-bearer redemption.

Co-authored-by: mecatl <noreply@mecatl.dev>
@github-actions github-actions Bot added the size/XL Extra large PR: 1000+ lines changed label Sep 17, 2026
@codecov

codecov Bot commented Sep 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.82759% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.12%. Comparing base (74c659b) to head (8064af0).

Files with missing lines Patch % Lines
pkg/authserver/server_impl.go 83.33% 2 Missing ⚠️
.../authserver/server/tokenexchange/id_jag_handler.go 88.88% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6678      +/-   ##
==========================================
+ Coverage   79.11%   79.12%   +0.01%     
==========================================
  Files         786      786              
  Lines       78538    78576      +38     
==========================================
+ Hits        62138    62176      +38     
  Misses      16395    16395              
  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.

@aron-muon aron-muon 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.

Ran this branch through the same calibrated environment used to verify #6677 (whole-repo task targets, with a known baseline of 12 packages that fail identically on pristine main for environment reasons — workloads tmp-path tests, a pkg/api/v1 panic, network-dependent retriever, etc.):

  • task test: only the 12 baseline packages fail; zero new failures. All tokenexchange tests pass, including the new filtering/gating coverage. (The socket-path failure your test plan mentions didn't reproduce here, consistent with it being a tmpdir-length environment issue.)
  • task lint: two real findings that will fail the Lint job — lll (>130 chars) on both new AcceptedAssertionTypes field lines:
    • pkg/authserver/inbound_grants.go:43 (137 chars)
    • pkg/authserver/server/tokenexchange/multi_issuer_validator.go:160 (134 chars)

On the design: agreed that accepting ID-JAGs implicitly wherever a jwt-bearer policy exists widened the issuer trust decision without an explicit opt-in — #6677 consciously piggybacked on the existing policy to avoid new config surface, and this is the right correction. The fail-loud config validation (accepted_audiences must include the AS issuer when id_jag is enabled) also matches what we hit in practice: our live Okta deployment already carries the bare issuer URL in acceptedAudiences because Okta mints the JAG aud from the resource connection's audience field, so real-world configs pass this check naturally.

Two observations, neither blocking:

  1. Upgrade note worth a line in the release notes: a deployment that enabled ID-JAG under #6677's behavior (ours is presumably the only one) silently loses it on upgrade until accepted_assertion_types: [jwt_bearer, id_jag] is added. For deployments running dev images on released charts, the new CRD field is pruned by the apiserver until the next chart release ships updated CRDs — so the practical migration there is "hold the image pin until the chart lands, or hand-apply the updated CRDs." Fine for us, just worth stating so nobody debugs a silent invalid_grant regression.

  2. Diagnostics regression at the no-opt-in boundary: when no issuer accepts id_jag, the bound handler isn't registered at all, so a recognized oauth-id-jag+jwt assertion falls through to fosite's generic invalid_request ("...redirect_uri..." boilerplate) — the exact hard-to-diagnose failure mode that motivated #6676 (open question 3 there). The constructor's at-least-one-issuer requirement is why registration is gated, so one option is relaxing that requirement for the bound handler specifically: registered-with-empty-policies would reject with its precise "issuer is not enabled for this grant" hint instead. Happy to leave that as a follow-up either way.

The shared-subject-bindings consequence being documented rather than split per-type seems right for now — splitting bindings per assertion type would double the config surface for a case nobody has yet.

Two lll violations on the new AcceptedAssertionTypes struct-tag
lines cannot be wrapped, so mark them nolint like other unwrappable
tag/marker lines in this repo.

Register IDJAGIssuanceFactory whenever the jwt-bearer grant type is
enabled, even with zero opted-in issuers, so a well-formed
"oauth-id-jag+jwt" assertion is claimed and rejected with the
precise "issuer is not enabled for this grant" hint instead of
falling through every registered handler to fosite's generic
invalid_request.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Extra large PR: 1000+ lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants