Skip to content

[v1.x] Validate the authorization server metadata issuer on every discovery path - #3431

Merged
maxisbey merged 3 commits into
v1.xfrom
oauth-issuer-binding-v1x
Sep 4, 2026
Merged

[v1.x] Validate the authorization server metadata issuer on every discovery path#3431
maxisbey merged 3 commits into
v1.xfrom
oauth-issuer-binding-v1x

Conversation

@maxisbey

@maxisbey maxisbey commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

v1.x backport of #3398 and #3435.

The OAuth client on 1.x now decides which issuer it expects before it fetches any authorization server metadata and checks what follows against that one value: the metadata's issuer (RFC 8414 section 3.3), which 1.x did not check on any path; the binding of stored client credentials to the authorization server they were registered with, which 1.x did not have; and a 403 scope step-up that goes through the same discovery when no metadata is held. The two machine-to-machine providers gain the optional issuer= keyword.

Motivation and Context

Same as #3398. On 1.x the client accepted authorization server metadata without comparing its issuer to the URL the well-known was built from, and stored client_id/client_secret carried no record of which server issued them, so they were presented to whichever authorization server the resource advertised next (or, on the 2025-03-26 fallback, to the resource server's own origin). This brings the 1.x client to the behaviour main has: the expected issuer is the PRM-advertised server, or on the no-PRM path the resource server's origin; metadata that names anything else is refused (a root issuer with and without its trailing slash is the same server); a registration bound to a different issuer is dropped, together with its tokens, before any metadata is fetched, and the client re-registers; newly registered clients are bound to the expected issuer; a 403 insufficient_scope with no metadata held discovers first; a 5xx/429 on the resource metadata request with no location answering stops the flow; and ClientCredentialsOAuthProvider / PrivateKeyJWTOAuthProvider accept issuer= so pre-provisioned credentials only go to that server.

Because 1.x had no credential binding, this also carries the parts of #2921, #2933, #2936, #2946 and #3181 that the change relies on: OAuthClientInformationFull.issuer (recorded by the SDK, never read from a registration response), validate_metadata_issuer, credentials_match_issuer and issuers_match in mcp.client.auth.utils, and the registration step recording the issuer.

Differences from #3398:

  • RFC 9207 iss validation is not included; the callback contract on 1.x stays tuple[str, str | None].
  • 1.x has no SEP-2350 scope union, so a step-up asks for the challenged scope as before.
  • Every parsed issuer renders with a trailing slash on 1.x, so the root-slash allowance lives in validate_metadata_issuer itself (new here) rather than only on the legacy path, and recorded issuers carry the slash.
  • The issuer= keyword follows scopes (the 1.x name); there is no docs page for these providers on 1.x, so the docstrings carry the description.
  • Deprecate constructing the pre-provisioned OAuth clients without an issuer #3435's deprecation of constructing the two providers without issuer= is included as a third commit; 1.x has no MCPDeprecationWarning, so it warns with DeprecationWarning like the other deprecations on this branch, and the note lives in the docstrings (1.x has no deprecations docs page).
  • The conformance harness pinned on 1.x (0.1.13) serves, in auth/metadata-var2 and auth/metadata-var3, authorization server metadata whose issuer omits the tenant path its resource metadata advertises, so a client that checks RFC 8414 section 3.3 refuses it. Those two scenarios are listed in expected-failures.yml with that note; the mock includes the path from conformance 0.1.15 (fix: include tenant path in auth server issuer claim conformance#152), so the entries go when the pin moves past it. (Bumping the pin here instead would trade them for two unrelated baseline entries, so that is left for its own change.)

How Has This Been Tested?

New tests in tests/client/test_auth.py and tests/client/auth/extensions/test_client_credentials.py, ported from #3398 and from main's credential-binding suite: issuer check on the PRM and legacy paths, root-slash tolerance and its limits, binding kept/discarded/stamped/not-stamped cases, registration responses cannot seed the binding, legacy path with no metadata (from a 401 and from a 403), step-up with and without metadata held, non-challenge 403, PRM 5xx/429, issuer= on both providers. The flow tests were checked to fail against v1.x's oauth2.py with the new helpers in place (they import functions 1.x did not have, so the plain "restore src/" recipe cannot collect them). I also drove the 1.x provider through httpx.AsyncClient(auth=...) and streamablehttp_client against local test servers for the PRM and legacy paths, 401-first and 403-first, and client-credentials with and without issuer=. Full suite, coverage, pyright and ruff pass locally.

Breaking Changes

No API removals or signature changes; additions are the optional issuer= keyword, the optional OAuthClientInformationFull.issuer field (round-trips through the unchanged TokenStorage methods; the server side does not emit it), and three functions in mcp.client.auth.utils. Observable differences:

  • Authorization server metadata whose issuer is not the advertised server (or, without protected resource metadata, the resource server's origin) now gets OAuthFlowError: Authorization server metadata issuer mismatch. No client-side override, as on main.
  • Stored credentials the SDK registers from now on carry an issuer; when the resource later names a different authorization server they are discarded and the client re-registers (one extra POST /register). Records stored by earlier 1.x releases carry no issuer and are left as they are.
  • A scope step-up performs discovery first when no metadata is held; a 403 without insufficient_scope is no longer retried.
  • When no protected resource metadata location answers and one returned a 5xx or 429, the flow raises instead of taking the legacy path.
  • An issuer member in a dynamic client registration response is ignored.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I am assigned to the linked issue (or it is labeled help wanted, or I'm a maintainer)
  • I have disclosed any AI assistance and can explain the change in my own words
  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Three commits: the discovery change (with the binding layer it needs on 1.x), the issuer= keyword, then its deprecation-when-omitted from #3435. The discovery block in async_auth_flow is re-indented under one new condition; the whitespace-insensitive view is the easier read.

AI Disclaimer

@maxisbey
maxisbey marked this pull request as ready for review September 2, 2026 16:32
…r metadata

Backport of the discovery half of #3398, together with the SEP-2352
credential binding that main has had since 2.0 and that change relies on
(the parts of #2921, #2933, #2936, #2946 and #3181 it needs).

The discovery step now knows which issuer the authorization server
metadata must carry before fetching it: the PRM-advertised server, or on
the 2025-03-26 no-PRM fallback the resource server's origin, which is
what that well-known URL is built from (RFC 8414 section 3.3). On this
line there was no issuer check at all; it now runs on both paths
(`validate_metadata_issuer`, with `issuers_match`: exact, except that a
root issuer with and without its trailing slash is the same server,
which matters here because every parsed issuer renders with the slash).
Stored client credentials gain the binding main has: the SDK records the
issuer a registration was made with (`OAuthClientInformationFull.issuer`,
never read from the registration response), and before any metadata is
fetched a registration bound to a different issuer is discarded together
with its tokens so the flow re-registers instead of presenting another
server's credentials (`credentials_match_issuer`); newly registered
clients are bound to the expected issuer when metadata for it was found and
the registration actually went to that server.
When no protected resource metadata location answers and one of them
failed with a server error (or 429), the flow stops rather than reading
that as "no such metadata".

A 403 insufficient_scope step-up takes the same path as a 401: it
discovers first when no metadata is held (extract_resource_metadata_from_www_auth
also reads the `resource_metadata` hint from a 403 challenge) and then
re-authorizes with the challenged scope; metadata already discovered in
this process is reused as before. A 403 that is not a scope challenge is
handed back to the caller instead of being retried unchanged.

The conformance harness pinned on this line (0.1.13) still serves, in its
two path-based authorization server scenarios, metadata whose `issuer` omits
the advertised tenant path; those two scenarios are listed as expected
failures until the pin moves past conformance 0.1.15, where the mock was
corrected.

Differences from the main change:
- RFC 9207 `iss` validation is not included; the callback contract on
  1.x stays `tuple[str, str | None]`.
- 1.x has no SEP-2350 scope union, so a step-up asks for the challenged
  scope as it did before.
- The root-slash allowance lives in `validate_metadata_issuer` itself
  (new on this line) rather than only on the legacy path.
Backport of the second half of #3398.

ClientCredentialsOAuthProvider and PrivateKeyJWTOAuthProvider take an
optional issuer keyword: the issuer identifier of the authorization
server the fixed client_id (and secret) were issued by. When set, token
requests are only built from discovered authorization server metadata
whose issuer matches it; if discovery yields metadata for another
server, or none at all, the flow stops with OAuthFlowError before the
secret is attached or an assertion is minted, and the metadata and
tokens held are dropped so the next request starts discovery again.
When the resource advertises several authorization servers the one
matching the configured issuer is used; the comparison is issuers_match
(exact, root slash aside); a value that is not an http(s) URL is a
ValueError. Omitting it keeps the current behaviour.

Differences from the main change: the keyword follows `scopes` (the 1.x
name); there is no docs page for these providers on 1.x, so the
docstrings carry the description.
@maxisbey
maxisbey force-pushed the oauth-issuer-binding-v1x branch from b4c142c to e5b6719 Compare September 2, 2026 16:36

@claude claude Bot 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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread src/mcp/client/auth/extensions/client_credentials.py
Comment thread src/mcp/client/auth/oauth2.py
Comment thread src/mcp/client/auth/oauth2.py
Comment thread src/mcp/client/auth/oauth2.py

@claude claude Bot 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.

This pull request has been reviewed before and this review found new issues. Where they share a root cause, one fix may close them together.

Still open from earlier reviews (4):

  • Unresolved: 4 minor or pre-existing.

Comment thread src/mcp/client/auth/oauth2.py
Comment thread src/mcp/client/auth/oauth2.py

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review completed against the latest diff

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/mcp/client/auth/oauth2.py
Comment thread src/mcp/client/auth/oauth2.py
Comment thread src/mcp/client/auth/extensions/client_credentials.py
Comment thread src/mcp/client/auth/extensions/client_credentials.py
Comment thread src/mcp/client/auth/oauth2.py
Comment thread src/mcp/client/auth/extensions/client_credentials.py Outdated
…ut an issuer

Backport of #3435. Omitting `issuer=` on ClientCredentialsOAuthProvider and
PrivateKeyJWTOAuthProvider keeps working and now warns at construction that
it will be required in 3.0, saying why (without it the MCP server decides
which authorization server receives the credentials) and what to pass.

Difference from main: 1.x has no MCPDeprecationWarning, so the category is
DeprecationWarning like the other deprecations on this branch, and there is
no deprecations docs page to list it on; the docstrings carry the note and
the module's examples pass `issuer=`.

@claude claude Bot 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.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/mcp/client/auth/extensions/client_credentials.py
@maxisbey
maxisbey merged commit 3eed7ce into v1.x Sep 4, 2026
29 checks passed
@maxisbey
maxisbey deleted the oauth-issuer-binding-v1x branch September 4, 2026 17:40
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.

1 participant