Skip to content

Commit 0c244f6

Browse files
committed
Let pre-provisioned OAuth clients name their authorization server
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, the client_credentials exchange and any refresh, 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. This is the same "the authorization server is configuration" model that IdentityAssertionOAuthProvider already uses, made available to the two older machine-to-machine providers without changing their defaults.
1 parent 38a3f0a commit 0c244f6

5 files changed

Lines changed: 287 additions & 4 deletions

File tree

docs/client/oauth-clients.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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_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)