Skip to content

Commit d060b36

Browse files
authored
Validate the authorization server metadata issuer on every discovery path (#3398)
1 parent d2290ca commit d060b36

9 files changed

Lines changed: 760 additions & 135 deletions

File tree

docs/client/oauth-clients.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ Look at `main()`. The provider goes on the **httpx2 client**, the httpx2 client
7676

7777
The first time `Client` sends a request, the server answers `401`. The provider takes over:
7878

79-
1. **Discovery.** It reads the `WWW-Authenticate` header, fetches the server's Protected Resource Metadata from `/.well-known/oauth-protected-resource`, learns which authorization server protects this resource, and fetches *that* server's metadata.
79+
1. **Discovery.** It reads the `WWW-Authenticate` header, fetches the server's Protected Resource Metadata from `/.well-known/oauth-protected-resource`, learns which authorization server protects this resource, and fetches *that* server's metadata. (An older server that publishes no resource metadata is asked for authorization server metadata at its own origin instead.) Either way the metadata must name, as its `issuer`, the server it was fetched for; anything else is refused.
8080
2. **Registration.** Nothing in storage? It registers you dynamically with your `OAuthClientMetadata` and stores the result.
8181
3. **Authorization.** It generates the PKCE pair and a `state`, builds the authorization URL, awaits your `redirect_handler`, then awaits your `callback_handler` for the code.
8282
4. **Exchange.** It trades the code for an `OAuthToken`, stores it, and replays your original request with `Authorization: Bearer ...`.
@@ -105,13 +105,14 @@ A nightly job, a CI step, another service. There is no browser and nobody to cli
105105

106106
`ClientCredentialsOAuthProvider` is the same `httpx2.Auth`, minus the human:
107107

108-
```python title="client.py" hl_lines="4 27-33"
108+
```python title="client.py" hl_lines="4 27-34"
109109
--8<-- "docs_src/oauth_clients/tutorial002.py"
110110
```
111111

112112
What changed:
113113

114114
* No `OAuthClientMetadata`, no handlers. You pass `client_id` and `client_secret`; the provider builds a minimal `client_credentials` registration around them and skips dynamic registration entirely.
115+
* `issuer` names the authorization server that issued those credentials; use the `issuer` value its `/.well-known/oauth-authorization-server` document returns. Discovery still runs as above, but token requests are only ever built from metadata for *that* issuer; if the MCP server points anywhere else, the flow stops with an `OAuthFlowError` instead. Leave it out and the provider uses whichever authorization server discovery finds.
115116
* `scope` is a space-separated string, the OAuth wire format.
116117
* Everything downstream is identical: the same `TokenStorage`, the same `httpx2.AsyncClient(auth=...)`, the same `streamable_http_client`.
117118

@@ -124,7 +125,7 @@ By default the secret travels as HTTP Basic auth on the token request (`client_s
124125
One more provider lives in `mcp.client.auth.extensions.client_credentials`:
125126
**`PrivateKeyJWTOAuthProvider`**, for clients that authenticate with a JWT instead of a
126127
shared secret (`private_key_jwt`, the key-pair and workload-identity flavour). It follows
127-
the same pattern: construct one, put it on `auth=`. The same module ships
128+
the same pattern: construct one (it takes the same optional `issuer`), put it on `auth=`. The same module ships
128129
`SignedJWTParameters` and `static_assertion_provider`, two helpers that build its assertion.
129130

130131
There is one more no-human situation: the client belongs to an enterprise whose identity provider, not the user, decides which MCP servers it may reach. That is a different grant with its own trust model and its own page, **[Identity assertion](identity-assertion.md)**.

docs/migration.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2499,7 +2499,9 @@ metadata's `issuer` exactly matches the authorization server URL advertised in t
24992499
resource metadata, as required by [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414)
25002500
section 3.3 ([SEP-2468](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2468)).
25012501
The comparison is a simple string comparison ([RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986)
2502-
section 6.2.1), so even a trailing-slash disagreement counts as a mismatch. v1 accepted the
2502+
section 6.2.1), so even a trailing-slash disagreement counts as a mismatch. (For an older server
2503+
that publishes no protected resource metadata the expected value is the MCP server's own origin,
2504+
and there a root issuer with a trailing slash is accepted too.) v1 accepted the
25032505
metadata without checking, so a server pairing whose two values disagree authenticated fine
25042506
under v1 and now fails the entire flow. For example, when the MCP server's protected resource
25052507
metadata advertises
@@ -2517,7 +2519,7 @@ OAuthFlowError: Authorization server metadata issuer mismatch: https://as.exampl
25172519

25182520
There is no client-side override. Fix the deployment instead: make the authorization server's
25192521
`issuer` string-equal the URL in the protected resource metadata's `authorization_servers`
2520-
list. See [OAuth metadata URLs no longer gain a trailing slash](#oauth-metadata-urls-no-longer-gain-a-trailing-slash)
2522+
list (or the MCP server's origin, without protected resource metadata). See [OAuth metadata URLs no longer gain a trailing slash](#oauth-metadata-urls-no-longer-gain-a-trailing-slash)
25212523
for how v2 preserves the exact string form of these URLs.
25222524

25232525
### OAuth client requests `offline_access` and adds `prompt=consent` when the authorization server supports it ([SEP-2207](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207))

docs_src/oauth_clients/tutorial002.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ async def set_client_info(self, client_info: OAuthClientInformationFull) -> None
3030
client_id="reporting-agent",
3131
client_secret="...",
3232
scope="user",
33+
issuer="http://localhost:9000",
3334
)
3435

3536

src/mcp/client/auth/extensions/client_credentials.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,58 @@
99
import time
1010
from collections.abc import Awaitable, Callable
1111
from typing import Any, Literal
12+
from urllib.parse import urlparse
1213
from uuid import uuid4
1314

1415
import httpx2
1516
import jwt
1617
from pydantic import BaseModel, Field
1718

1819
from mcp.client.auth import OAuthClientProvider, OAuthFlowError, TokenStorage
20+
from mcp.client.auth.oauth2 import OAuthContext
21+
from mcp.client.auth.utils import issuers_match
1922
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
2023

2124

25+
def _checked_issuer(issuer: str | None) -> str | None:
26+
if issuer is not None and urlparse(issuer).scheme not in ("http", "https"):
27+
raise ValueError(f"issuer must be the authorization server's http(s) issuer URL, got {issuer!r}")
28+
return issuer
29+
30+
31+
def _preferred_authorization_server(advertised: list[str], issuer: str | None) -> str:
32+
"""The advertised server matching the configured issuer if there is one, else the first."""
33+
return next(
34+
(server for server in advertised if issuer is not None and issuers_match(server, issuer)), advertised[0]
35+
)
36+
37+
38+
def _require_metadata_for_configured_issuer(context: OAuthContext, issuer: str | None) -> None:
39+
"""With an issuer configured, a token request is only built from metadata discovered for that issuer.
40+
41+
Anything else held is dropped along with the tokens, so the next request starts discovery afresh
42+
rather than refreshing against it.
43+
"""
44+
if issuer is None:
45+
return
46+
metadata = context.oauth_metadata
47+
if metadata is not None and issuers_match(str(metadata.issuer), issuer):
48+
return
49+
context.oauth_metadata = None
50+
context.clear_tokens()
51+
if metadata is None:
52+
raise OAuthFlowError(f"No authorization server metadata discovered for configured issuer {issuer}")
53+
raise OAuthFlowError(f"Authorization server metadata issuer mismatch: {metadata.issuer} != {issuer}")
54+
55+
2256
class ClientCredentialsOAuthProvider(OAuthClientProvider):
2357
"""OAuth provider for client_credentials grant with client_id + client_secret.
2458
2559
This provider sets client_info directly, bypassing dynamic client registration.
2660
Use this when you already have client credentials (client_id and client_secret).
61+
Pass `issuer` to name the authorization server those credentials belong to: token
62+
requests are then only built from authorization server metadata for that issuer, and
63+
the flow stops if the MCP server leads anywhere else.
2764
2865
Example:
2966
```python
@@ -32,6 +69,7 @@ class ClientCredentialsOAuthProvider(OAuthClientProvider):
3269
storage=my_token_storage,
3370
client_id="my-client-id",
3471
client_secret="my-client-secret",
72+
issuer="https://auth.example.com",
3573
)
3674
```
3775
"""
@@ -44,6 +82,7 @@ def __init__(
4482
client_secret: str,
4583
token_endpoint_auth_method: Literal["client_secret_basic", "client_secret_post"] = "client_secret_basic",
4684
scope: str | None = None,
85+
issuer: str | None = None,
4786
) -> None:
4887
"""Initialize client_credentials OAuth provider.
4988
@@ -55,6 +94,11 @@ def __init__(
5594
token_endpoint_auth_method: Authentication method for token endpoint.
5695
Either "client_secret_basic" (default) or "client_secret_post".
5796
scope: Optional space-separated list of scopes to request.
97+
issuer: The issuer identifier of the authorization server that issued
98+
`client_id` and `client_secret`. When set, token requests are only built from
99+
discovered authorization server metadata whose `issuer` is exactly this string;
100+
otherwise the flow stops with `OAuthFlowError`. When omitted, whichever
101+
authorization server discovery yields is used.
58102
"""
59103
# Build minimal client_metadata for the base class
60104
client_metadata = OAuthClientMetadata(
@@ -64,6 +108,7 @@ def __init__(
64108
scope=scope,
65109
)
66110
super().__init__(server_url, client_metadata, storage, None, None)
111+
self._issuer = _checked_issuer(issuer)
67112
# Store client_info to be set during _initialize - no dynamic registration needed
68113
self._fixed_client_info = OAuthClientInformationFull(
69114
redirect_uris=None,
@@ -80,12 +125,17 @@ async def _initialize(self) -> None:
80125
self.context.client_info = self._fixed_client_info
81126
self._initialized = True
82127

128+
def _select_authorization_server(self, advertised: list[str]) -> str:
129+
return _preferred_authorization_server(advertised, self._issuer)
130+
83131
async def _perform_authorization(self) -> httpx2.Request:
84132
"""Perform client_credentials authorization."""
85133
return await self._exchange_token_client_credentials()
86134

87135
async def _exchange_token_client_credentials(self) -> httpx2.Request:
88136
"""Build token exchange request for client_credentials grant."""
137+
_require_metadata_for_configured_issuer(self.context, self._issuer)
138+
89139
token_data: dict[str, Any] = {
90140
"grant_type": "client_credentials",
91141
}
@@ -196,7 +246,10 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider):
196246
197247
The JWT assertion's audience MUST be the authorization server's issuer identifier
198248
(per RFC 7523bis security updates). The `assertion_provider` callback receives
199-
this audience value and must return a JWT with that audience.
249+
this audience value and must return a JWT with that audience. Pass `issuer` to name
250+
the authorization server this client is registered with: an assertion is then only
251+
minted once metadata for that issuer has been discovered, and token requests are only
252+
built from that metadata.
200253
201254
**Option 1: Pre-built JWT via Workload Identity Federation**
202255
@@ -256,6 +309,7 @@ def __init__(
256309
client_id: str,
257310
assertion_provider: Callable[[str], Awaitable[str]],
258311
scope: str | None = None,
312+
issuer: str | None = None,
259313
) -> None:
260314
"""Initialize private_key_jwt OAuth provider.
261315
@@ -269,6 +323,11 @@ def __init__(
269323
`static_assertion_provider()` for pre-built JWTs, or provide your own
270324
callback for workload identity federation.
271325
scope: Optional space-separated list of scopes to request.
326+
issuer: The issuer identifier of the authorization server `client_id` is
327+
registered with. When set, an assertion is only minted, and token requests
328+
are only built, once authorization server metadata whose `issuer` is exactly this
329+
string has been discovered; otherwise the flow stops with `OAuthFlowError`.
330+
When omitted, whichever authorization server discovery yields is used.
272331
"""
273332
# Build minimal client_metadata for the base class
274333
client_metadata = OAuthClientMetadata(
@@ -279,6 +338,7 @@ def __init__(
279338
)
280339
super().__init__(server_url, client_metadata, storage, None, None)
281340
self._assertion_provider = assertion_provider
341+
self._issuer = _checked_issuer(issuer)
282342
# Store client_info to be set during _initialize - no dynamic registration needed
283343
self._fixed_client_info = OAuthClientInformationFull(
284344
redirect_uris=None,
@@ -294,6 +354,9 @@ async def _initialize(self) -> None:
294354
self.context.client_info = self._fixed_client_info
295355
self._initialized = True
296356

357+
def _select_authorization_server(self, advertised: list[str]) -> str:
358+
return _preferred_authorization_server(advertised, self._issuer)
359+
297360
async def _perform_authorization(self) -> httpx2.Request:
298361
"""Perform client_credentials authorization with private_key_jwt."""
299362
return await self._exchange_token_client_credentials()
@@ -314,6 +377,8 @@ async def _add_client_authentication_jwt(self, *, token_data: dict[str, Any]) ->
314377

315378
async def _exchange_token_client_credentials(self) -> httpx2.Request:
316379
"""Build token exchange request for client_credentials grant with private_key_jwt."""
380+
_require_metadata_for_configured_issuer(self.context, self._issuer)
381+
317382
token_data: dict[str, Any] = {
318383
"grant_type": "client_credentials",
319384
}

0 commit comments

Comments
 (0)