Skip to content

feat(auth): accept trusted IdP tokens without local provisioning in trust mode - #6750

Open
jonpspri wants to merge 5 commits into
feat/5902-admin-claim-parityfrom
feat/5903-external-idp-trust-root
Open

feat(auth): accept trusted IdP tokens without local provisioning in trust mode#6750
jonpspri wants to merge 5 commits into
feat/5902-admin-claim-parityfrom
feat/5903-external-idp-trust-root

Conversation

@jonpspri

@jonpspri jonpspri commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

build_external_identity and _maybe_verify_external now branch on trust mode: when jwt_trust_mode="jwt-trust" and the issuer is a configured trust root (SSOProvider.trusted_for_api_auth + api_audience), the identity builds from verified claims via extract_trusted_principaltoken_use="trusted", authenticate_or_create_user provisioning skipped, no get_user_by_email, no DB is_admin. JWKS validation and iss/aud checks are unchanged; default-mode provisioning is untouched. External group IDs map through resolve_external_groups_to_teams; overage tokens dispatch on jwt_trust_overage_policy (graph_lookup uses the #5977 app-only client). A trust-eligible token missing the configured revocation claim (jti default, uti supported) is rejected. invalidate_external_identity_cache covers the claims-derived path (token-hash key, tested).

The B.1 dispatch-matrix external-IdP row flips from strict-xfail to green in this commit — the matrix file now contains zero xfail markers. The flip required one added line (jwt_trust_mode monkeypatch) so the row exercises what its own contract states.

Tested with:

  • uv run pytest tests/unit/mcpgateway/utils/test_external_idp_trust_mode.py -q — 9 passed (TDD: 8 failed first — the provisioning path ran). Includes the SQLAlchemy before_cursor_execute recorder proof: ZERO INSERT and ZERO SELECT against email_users on the trust path; teams/roles from seeded mapping rows with no user row; is_admin from the mapped claim.
  • uv run pytest tests -k "external_idp or sso_token" -q — 86 tests, 0 failures
  • uv run pytest tests/unit/mcpgateway/test_token_dispatch_matrix.py -q — 6 passed, zero xfail
  • make ruff — all checks passed; make test — 23343 passed, 879 skipped, 2 xfailed (the two pre-existing plugin rows — the last trust xfails are gone)

Acceptance criteria of #5903 are met. Risk to existing users: none — default mode byte-identical.

Stack: B.11 of epic #5885 (base: #6749).

Closes #5903

@jonpspri
jonpspri added this pull request to stack #6729 September 10, 2026 02:58
@jonpspri
jonpspri marked this pull request as ready for review September 10, 2026 02:58
@jonpspri
jonpspri force-pushed the feat/5903-external-idp-trust-root branch from 8920db4 to d058fb3 Compare September 10, 2026 06:23
@jonpspri
jonpspri removed this pull request from stack #6729 September 12, 2026 08:50
@jonpspri
jonpspri force-pushed the feat/5903-external-idp-trust-root branch from d058fb3 to 1770eb7 Compare September 12, 2026 09:07
@jonpspri
jonpspri added this pull request to stack #6798 September 12, 2026 09:08
@jonpspri
jonpspri force-pushed the feat/5903-external-idp-trust-root branch 2 times, most recently from 1a75ab1 to 5b2727c Compare September 12, 2026 09:48
@jonpspri
jonpspri force-pushed the feat/5903-external-idp-trust-root branch from 5b2727c to 874b77b Compare September 12, 2026 09:52
@jonpspri
jonpspri force-pushed the feat/5903-external-idp-trust-root branch from 874b77b to 9b15009 Compare September 12, 2026 16:45
@jonpspri
jonpspri force-pushed the feat/5903-external-idp-trust-root branch from 9b15009 to 55a8cdf Compare September 12, 2026 17:20
@jonpspri
jonpspri force-pushed the feat/5903-external-idp-trust-root branch 2 times, most recently from 03ef307 to 2a7ced6 Compare September 12, 2026 17:54
@jonpspri
jonpspri force-pushed the feat/5903-external-idp-trust-root branch from 2a7ced6 to a46ea83 Compare September 12, 2026 18:09
@jonpspri
jonpspri force-pushed the feat/5903-external-idp-trust-root branch from a46ea83 to faf453c Compare September 12, 2026 18:33
@jonpspri
jonpspri force-pushed the feat/5903-external-idp-trust-root branch from faf453c to 7101c19 Compare September 12, 2026 18:52
@jonpspri
jonpspri force-pushed the feat/5903-external-idp-trust-root branch from 7101c19 to f8c768e Compare September 12, 2026 19:12
…rust mode

build_external_identity branches on trust mode: when jwt_trust_mode is "jwt-trust" and the issuer is a configured trust root, the identity is built from verified claims via extract_trusted_principal. No INSERT or SELECT against email_users. The revocation claim gate, the three overage policies, and the token-hash identity cache apply to the claims-derived path. The B.1 dispatch-matrix branch-(b) row flips from strict-xfail to green. Default mode is unchanged.

Signed-off-by: Jonathan Springer <jps@s390x.com>
…he authentication choke point

In JWT trust mode, get_current_user() now consults the external-IdP
verifier before the internal one. Dispatch is decided by the token's
unverified iss claim (read only for routing): a configured trust root
authenticates via the existing verify_external_idp_token ->
build_trusted_external_identity chain and the payload flows into the
token_use=trusted branch; a trust-root token that fails verification
definitively raises fail-closed (401) and never falls through to the
internal funnel; any other issuer falls through exactly as before.

The fail-closed distinction is expressed by
ExternalIssuerVerificationError and an opt-in fail_closed flag on
_maybe_verify_external; existing callers keep the legacy
fall-through-on-any-failure behavior.

Signed-off-by: Jonathan Springer <jps@s390x.com>
…s claim

With non-Entra trust roots live (#5903), the claim-collision guard in
validate_security_combinations now rejects jwt_claim_teams aliasing ANY of
sso_entra_groups_claim, sso_keycloak_groups_claim, or
sso_generic_groups_claim, compared case-insensitively, naming both
JWT_CLAIM_TEAMS and the offending variable.

Signed-off-by: Jonathan Springer <jps@s390x.com>
… matrix

The harness serves OIDC discovery and JWKS over TLS from a generated RSA
key (uvicorn in a thread, ephemeral port) plus a stub A2A agent endpoint,
with a mint_token helper and an idempotent ensure-material CLI for the
gateway's SSL_CERT_FILE.

The black-box matrix seeds a trust root, team, team-visible agent, and
group mapping through the admin API and pins the ingress contract:
mapped user -> 200, unmapped user -> 403 (RBAC deny), wrong audience ->
401, missing revocation claim -> 401, nonexistent agent with role -> 404.
The two rows blocked by the TokenScopingMiddleware membership check are
xfail(strict=False) pending the trusted-team exemption in #5904.

Signed-off-by: Jonathan Springer <jps@s390x.com>
With the ingress dispatch in place, the barrier scenario's 401 for the
valid Entra token is re-pinned as the correct by-design outcome: no
providers are seeded, so the Entra issuer is not a configured trust root
and the token falls through to the internal verifier. The seeded-issuer
denial paths (403 without mapping, 404 with role) are covered by the
external-issuer ingress matrix. Fake-token control unchanged.

Signed-off-by: Jonathan Springer <jps@s390x.com>
@jonpspri

Copy link
Copy Markdown
Collaborator Author

Requirement note (remediation) — the ingress fix

This PR now wires the dispatch that the review found missing (finding F1):

  • get_current_user peeks iss and sends trust-root bearers to the JWKS path first (5813a1254).
  • A trust-root issuer with a bad token gives 401. No fall-through.
  • Other issuers use the internal funnel.

Also on this PR: the collision guard widened to all three provider claims; the local OIDC issuer harness; the five-row live matrix; the barrier test re-pinned to the fall-through contract.

Live results: mapped 200, unmapped 403, wrong audience 401, missing revocation claim 401, nonexistent agent 404. See the notes on #5896 and #5903.

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.

External IdP trust root - claims-derived identity without provisioning

1 participant