Skip to content

Commit 3eed7ce

Browse files
authored
[v1.x] Validate the authorization server metadata issuer on every discovery path (#3431)
1 parent 92120b4 commit 3eed7ce

7 files changed

Lines changed: 1323 additions & 90 deletions

File tree

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
11
# Known conformance test failures for v1.x
22
# These are tracked and should be removed as they're fixed.
33
server: []
4-
client: []
4+
client:
5+
# The pinned harness (0.1.13) serves authorization server metadata whose `issuer`
6+
# omits the tenant path its resource metadata advertises (`/tenant1`), so a client
7+
# that checks RFC 8414 section 3.3 refuses it. The mock includes the path from
8+
# conformance 0.1.15 (modelcontextprotocol/conformance#152); drop these two entries
9+
# when the pin moves past it.
10+
- auth/metadata-var2
11+
- auth/metadata-var3

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

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,70 @@
99
"""
1010

1111
import time
12+
import warnings
1213
from collections.abc import Awaitable, Callable
1314
from typing import Any, Literal
15+
from urllib.parse import urlparse
1416
from uuid import uuid4
1517

1618
import httpx
1719
import jwt
1820
from pydantic import BaseModel, Field
1921

2022
from mcp.client.auth import OAuthClientProvider, OAuthFlowError, OAuthTokenError, TokenStorage
23+
from mcp.client.auth.oauth2 import OAuthContext
24+
from mcp.client.auth.utils import issuers_match
2125
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
2226

2327

28+
def _checked_issuer(issuer: str | None) -> str | None:
29+
if issuer is None:
30+
warnings.warn(
31+
"Omitting `issuer` is deprecated and it will be required in 3.0. Without it, the MCP server "
32+
"decides which authorization server receives this client's credentials; pass "
33+
"issuer=<your authorization server's issuer URL> so they are only ever sent there.",
34+
DeprecationWarning,
35+
stacklevel=3,
36+
)
37+
return None
38+
if urlparse(issuer).scheme not in ("http", "https"):
39+
raise ValueError(f"issuer must be the authorization server's http(s) issuer URL, got {issuer!r}")
40+
return issuer
41+
42+
43+
def _preferred_authorization_server(advertised: list[str], issuer: str | None) -> str:
44+
"""The advertised server matching the configured issuer if there is one, else the first."""
45+
return next(
46+
(server for server in advertised if issuer is not None and issuers_match(server, issuer)), advertised[0]
47+
)
48+
49+
50+
def _require_metadata_for_configured_issuer(context: OAuthContext, issuer: str | None) -> None:
51+
"""With an issuer configured, a token request is only built from metadata discovered for that issuer.
52+
53+
Anything else held is dropped along with the tokens, so the next request starts discovery afresh
54+
rather than refreshing against it.
55+
"""
56+
if issuer is None:
57+
return
58+
metadata = context.oauth_metadata
59+
if metadata is not None and issuers_match(str(metadata.issuer), issuer):
60+
return
61+
context.oauth_metadata = None
62+
context.clear_tokens()
63+
if metadata is None:
64+
raise OAuthFlowError(f"No authorization server metadata discovered for configured issuer {issuer}")
65+
raise OAuthFlowError(f"Authorization server metadata issuer mismatch: {metadata.issuer} != {issuer}")
66+
67+
2468
class ClientCredentialsOAuthProvider(OAuthClientProvider):
2569
"""OAuth provider for client_credentials grant with client_id + client_secret.
2670
2771
This provider sets client_info directly, bypassing dynamic client registration.
2872
Use this when you already have client credentials (client_id and client_secret).
73+
Pass `issuer` to name the authorization server those credentials belong to: token
74+
requests are then only built from authorization server metadata for that issuer, and
75+
the flow stops if the MCP server leads anywhere else.
2976
3077
Example:
3178
```python
@@ -34,6 +81,7 @@ class ClientCredentialsOAuthProvider(OAuthClientProvider):
3481
storage=my_token_storage,
3582
client_id="my-client-id",
3683
client_secret="my-client-secret",
84+
issuer="https://auth.example.com",
3785
)
3886
```
3987
"""
@@ -46,6 +94,7 @@ def __init__(
4694
client_secret: str,
4795
token_endpoint_auth_method: Literal["client_secret_basic", "client_secret_post"] = "client_secret_basic",
4896
scopes: str | None = None,
97+
issuer: str | None = None,
4998
) -> None:
5099
"""Initialize client_credentials OAuth provider.
51100
@@ -57,6 +106,12 @@ def __init__(
57106
token_endpoint_auth_method: Authentication method for token endpoint.
58107
Either "client_secret_basic" (default) or "client_secret_post".
59108
scopes: Optional space-separated list of scopes to request.
109+
issuer: The issuer identifier of the authorization server that issued
110+
`client_id` and `client_secret`. When set, token requests are only built from
111+
discovered authorization server metadata whose `issuer` is exactly this string;
112+
otherwise the flow stops with `OAuthFlowError`. Omitting it is deprecated
113+
(`DeprecationWarning`) and it will be required in 3.0; until then, whichever
114+
authorization server discovery yields is used.
60115
"""
61116
# Build minimal client_metadata for the base class
62117
client_metadata = OAuthClientMetadata(
@@ -66,6 +121,7 @@ def __init__(
66121
scope=scopes,
67122
)
68123
super().__init__(server_url, client_metadata, storage, None, None, 300.0)
124+
self._issuer = _checked_issuer(issuer)
69125
# Store client_info to be set during _initialize - no dynamic registration needed
70126
self._fixed_client_info = OAuthClientInformationFull(
71127
redirect_uris=None,
@@ -82,12 +138,17 @@ async def _initialize(self) -> None:
82138
self.context.client_info = self._fixed_client_info
83139
self._initialized = True
84140

141+
def _select_authorization_server(self, advertised: list[str]) -> str:
142+
return _preferred_authorization_server(advertised, self._issuer)
143+
85144
async def _perform_authorization(self) -> httpx.Request:
86145
"""Perform client_credentials authorization."""
87146
return await self._exchange_token_client_credentials()
88147

89148
async def _exchange_token_client_credentials(self) -> httpx.Request:
90149
"""Build token exchange request for client_credentials grant."""
150+
_require_metadata_for_configured_issuer(self.context, self._issuer)
151+
91152
token_data: dict[str, Any] = {
92153
"grant_type": "client_credentials",
93154
}
@@ -120,6 +181,7 @@ def static_assertion_provider(token: str) -> Callable[[str], Awaitable[str]]:
120181
storage=my_token_storage,
121182
client_id="my-client-id",
122183
assertion_provider=static_assertion_provider(my_prebuilt_jwt),
184+
issuer="https://auth.example.com",
123185
)
124186
```
125187
@@ -154,6 +216,7 @@ class SignedJWTParameters(BaseModel):
154216
storage=my_token_storage,
155217
client_id="my-client-id",
156218
assertion_provider=jwt_params.create_assertion_provider(),
219+
issuer="https://auth.example.com",
157220
)
158221
```
159222
"""
@@ -198,7 +261,10 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider):
198261
199262
The JWT assertion's audience MUST be the authorization server's issuer identifier
200263
(per RFC 7523bis security updates). The `assertion_provider` callback receives
201-
this audience value and must return a JWT with that audience.
264+
this audience value and must return a JWT with that audience. Pass `issuer` to name
265+
the authorization server this client is registered with: an assertion is then only
266+
minted once metadata for that issuer has been discovered, and token requests are only
267+
built from that metadata.
202268
203269
**Option 1: Pre-built JWT via Workload Identity Federation**
204270
@@ -216,6 +282,7 @@ async def get_workload_identity_token(audience: str) -> str:
216282
storage=my_token_storage,
217283
client_id="my-client-id",
218284
assertion_provider=get_workload_identity_token,
285+
issuer="https://auth.example.com",
219286
)
220287
```
221288
@@ -229,6 +296,7 @@ async def get_workload_identity_token(audience: str) -> str:
229296
storage=my_token_storage,
230297
client_id="my-client-id",
231298
assertion_provider=static_assertion_provider(my_prebuilt_jwt),
299+
issuer="https://auth.example.com",
232300
)
233301
```
234302
@@ -247,6 +315,7 @@ async def get_workload_identity_token(audience: str) -> str:
247315
storage=my_token_storage,
248316
client_id="my-client-id",
249317
assertion_provider=jwt_params.create_assertion_provider(),
318+
issuer="https://auth.example.com",
250319
)
251320
```
252321
"""
@@ -258,6 +327,7 @@ def __init__(
258327
client_id: str,
259328
assertion_provider: Callable[[str], Awaitable[str]],
260329
scopes: str | None = None,
330+
issuer: str | None = None,
261331
) -> None:
262332
"""Initialize private_key_jwt OAuth provider.
263333
@@ -271,6 +341,12 @@ def __init__(
271341
`static_assertion_provider()` for pre-built JWTs, or provide your own
272342
callback for workload identity federation.
273343
scopes: Optional space-separated list of scopes to request.
344+
issuer: The issuer identifier of the authorization server `client_id` is
345+
registered with. When set, an assertion is only minted, and token requests
346+
are only built, once authorization server metadata whose `issuer` is exactly this
347+
string has been discovered; otherwise the flow stops with `OAuthFlowError`.
348+
Omitting it is deprecated (`DeprecationWarning`) and it will be required in
349+
3.0; until then, whichever authorization server discovery yields is used.
274350
"""
275351
# Build minimal client_metadata for the base class
276352
client_metadata = OAuthClientMetadata(
@@ -281,6 +357,7 @@ def __init__(
281357
)
282358
super().__init__(server_url, client_metadata, storage, None, None, 300.0)
283359
self._assertion_provider = assertion_provider
360+
self._issuer = _checked_issuer(issuer)
284361
# Store client_info to be set during _initialize - no dynamic registration needed
285362
self._fixed_client_info = OAuthClientInformationFull(
286363
redirect_uris=None,
@@ -296,6 +373,9 @@ async def _initialize(self) -> None:
296373
self.context.client_info = self._fixed_client_info
297374
self._initialized = True
298375

376+
def _select_authorization_server(self, advertised: list[str]) -> str:
377+
return _preferred_authorization_server(advertised, self._issuer)
378+
299379
async def _perform_authorization(self) -> httpx.Request:
300380
"""Perform client_credentials authorization with private_key_jwt."""
301381
return await self._exchange_token_client_credentials()
@@ -316,6 +396,8 @@ async def _add_client_authentication_jwt(self, *, token_data: dict[str, Any]) ->
316396

317397
async def _exchange_token_client_credentials(self) -> httpx.Request:
318398
"""Build token exchange request for client_credentials grant with private_key_jwt."""
399+
_require_metadata_for_configured_issuer(self.context, self._issuer)
400+
319401
token_data: dict[str, Any] = {
320402
"grant_type": "client_credentials",
321403
}
@@ -409,8 +491,6 @@ def __init__(
409491
timeout: float = 300.0,
410492
jwt_parameters: JWTParameters | None = None,
411493
) -> None:
412-
import warnings
413-
414494
warnings.warn(
415495
"RFC7523OAuthClientProvider is deprecated. Use ClientCredentialsOAuthProvider "
416496
"or PrivateKeyJWTOAuthProvider instead.",

0 commit comments

Comments
 (0)