Skip to content

Commit d85452b

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, discovery only proceeds with that server: the protected resource has to advertise it (with several advertised, its entry is the one used), or on the legacy no-PRM path the resource server's origin has to be it; anything else stops the flow with OAuthFlowError before any of that server's metadata is fetched. Token requests are then only built from metadata discovered for that issuer, never from the 2025-03-26 default endpoints: the client_credentials exchange requires it, and a refresh waits for it (OAuthContext.configured_issuer, can_refresh_token). 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 3bc0b4c commit d85452b

7 files changed

Lines changed: 424 additions & 8 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. Discovery still runs as above, but the token request is 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: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,48 @@
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 validate_metadata_issuer
1922
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
2023

2124

25+
def _configure_issuer(context: OAuthContext, issuer: str | None) -> None:
26+
"""Record `issuer` as the one authorization server this client works with (`OAuthContext.configured_issuer`)."""
27+
if issuer is None:
28+
return
29+
parsed = urlparse(issuer)
30+
if parsed.scheme not in ("http", "https") or not parsed.netloc:
31+
raise ValueError("issuer, when given, must be the authorization server's issuer URL")
32+
context.configured_issuer = issuer
33+
34+
35+
def _require_metadata_for_configured_issuer(context: OAuthContext) -> None:
36+
"""With an issuer configured, a token request is only built from metadata discovered for it."""
37+
if context.configured_issuer is None:
38+
return
39+
if context.oauth_metadata is None:
40+
raise OAuthFlowError(
41+
f"No authorization server metadata discovered for configured issuer {context.configured_issuer}"
42+
)
43+
validate_metadata_issuer(context.oauth_metadata, context.configured_issuer)
44+
45+
2246
class ClientCredentialsOAuthProvider(OAuthClientProvider):
2347
"""OAuth provider for client_credentials grant with client_id + client_secret.
2448
2549
This provider sets client_info directly, bypassing dynamic client registration.
2650
Use this when you already have client credentials (client_id and client_secret).
51+
Pass `issuer` to name the authorization server those credentials belong to: the flow then
52+
only proceeds when the MCP server leads to that server, and the client_credentials token
53+
request is only built from its metadata.
2754
2855
Example:
2956
```python
@@ -32,6 +59,7 @@ class ClientCredentialsOAuthProvider(OAuthClientProvider):
3259
storage=my_token_storage,
3360
client_id="my-client-id",
3461
client_secret="my-client-secret",
62+
issuer="https://auth.example.com",
3563
)
3664
```
3765
"""
@@ -44,6 +72,7 @@ def __init__(
4472
client_secret: str,
4573
token_endpoint_auth_method: Literal["client_secret_basic", "client_secret_post"] = "client_secret_basic",
4674
scope: str | None = None,
75+
issuer: str | None = None,
4776
) -> None:
4877
"""Initialize client_credentials OAuth provider.
4978
@@ -55,6 +84,12 @@ def __init__(
5584
token_endpoint_auth_method: Authentication method for token endpoint.
5685
Either "client_secret_basic" (default) or "client_secret_post".
5786
scope: Optional space-separated list of scopes to request.
87+
issuer: The issuer identifier of the authorization server that issued
88+
`client_id` and `client_secret`. When set, discovery only proceeds with that
89+
server (the resource must advertise it, or be it on the legacy path) and token
90+
requests are only built from its metadata; otherwise the flow stops with
91+
`OAuthFlowError`. When omitted, whichever authorization server discovery yields
92+
is used.
5893
"""
5994
# Build minimal client_metadata for the base class
6095
client_metadata = OAuthClientMetadata(
@@ -64,6 +99,7 @@ def __init__(
6499
scope=scope,
65100
)
66101
super().__init__(server_url, client_metadata, storage, None, None)
102+
_configure_issuer(self.context, issuer)
67103
# Store client_info to be set during _initialize - no dynamic registration needed
68104
self._fixed_client_info = OAuthClientInformationFull(
69105
redirect_uris=None,
@@ -86,6 +122,8 @@ async def _perform_authorization(self) -> httpx2.Request:
86122

87123
async def _exchange_token_client_credentials(self) -> httpx2.Request:
88124
"""Build token exchange request for client_credentials grant."""
125+
_require_metadata_for_configured_issuer(self.context)
126+
89127
token_data: dict[str, Any] = {
90128
"grant_type": "client_credentials",
91129
}
@@ -196,7 +234,10 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider):
196234
197235
The JWT assertion's audience MUST be the authorization server's issuer identifier
198236
(per RFC 7523bis security updates). The `assertion_provider` callback receives
199-
this audience value and must return a JWT with that audience.
237+
this audience value and must return a JWT with that audience. Pass `issuer` to name
238+
the authorization server this client is registered with: an assertion is then only
239+
minted once metadata for that issuer has been discovered, and sent to the token
240+
endpoint that metadata publishes.
200241
201242
**Option 1: Pre-built JWT via Workload Identity Federation**
202243
@@ -256,6 +297,7 @@ def __init__(
256297
client_id: str,
257298
assertion_provider: Callable[[str], Awaitable[str]],
258299
scope: str | None = None,
300+
issuer: str | None = None,
259301
) -> None:
260302
"""Initialize private_key_jwt OAuth provider.
261303
@@ -269,6 +311,12 @@ def __init__(
269311
`static_assertion_provider()` for pre-built JWTs, or provide your own
270312
callback for workload identity federation.
271313
scope: Optional space-separated list of scopes to request.
314+
issuer: The issuer identifier of the authorization server `client_id` is
315+
registered with. When set, discovery only proceeds with that server (the resource
316+
must advertise it, or be it on the legacy path) and an assertion is only minted
317+
once its metadata has been discovered; otherwise the flow stops with
318+
`OAuthFlowError`. When omitted, whichever authorization server discovery yields
319+
is used.
272320
"""
273321
# Build minimal client_metadata for the base class
274322
client_metadata = OAuthClientMetadata(
@@ -279,6 +327,7 @@ def __init__(
279327
)
280328
super().__init__(server_url, client_metadata, storage, None, None)
281329
self._assertion_provider = assertion_provider
330+
_configure_issuer(self.context, issuer)
282331
# Store client_info to be set during _initialize - no dynamic registration needed
283332
self._fixed_client_info = OAuthClientInformationFull(
284333
redirect_uris=None,
@@ -314,6 +363,8 @@ async def _add_client_authentication_jwt(self, *, token_data: dict[str, Any]) ->
314363

315364
async def _exchange_token_client_credentials(self) -> httpx2.Request:
316365
"""Build token exchange request for client_credentials grant with private_key_jwt."""
366+
_require_metadata_for_configured_issuer(self.context)
367+
317368
token_data: dict[str, Any] = {
318369
"grant_type": "client_credentials",
319370
}

src/mcp/client/auth/oauth2.py

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,9 @@ class OAuthContext:
160160
protected_resource_metadata: ProtectedResourceMetadata | None = None
161161
oauth_metadata: OAuthMetadata | None = None
162162
auth_server_url: str | None = None
163+
# Set by providers whose credentials were issued by one known authorization server: discovery
164+
# only proceeds with that server, and token requests are only built from its metadata.
165+
configured_issuer: str | None = None
163166
protocol_version: str | None = None
164167

165168
# Client registration
@@ -198,7 +201,13 @@ def is_token_valid(self) -> bool:
198201
)
199202

200203
def can_refresh_token(self) -> bool:
201-
"""Check if token can be refreshed."""
204+
"""Check if token can be refreshed.
205+
206+
With a configured issuer the refresh request is only built from that issuer's metadata, so
207+
none is attempted until it has been discovered (the request then re-authenticates instead).
208+
"""
209+
if self.configured_issuer is not None and self.oauth_metadata is None:
210+
return False
202211
return bool(self.current_tokens and self.current_tokens.refresh_token and self.client_info)
203212

204213
def clear_tokens(self) -> None:
@@ -587,12 +596,34 @@ async def _validate_resource_match(self, prm: ProtectedResourceMetadata) -> None
587596
raise OAuthFlowError(f"Protected resource {prm_resource} does not match expected {default_resource}")
588597

589598
def _select_authorization_server(self, prm: ProtectedResourceMetadata | None) -> str | None:
590-
"""Pick the authorization server for this pass from the protected resource metadata: the
591-
first advertised server, or None on the legacy no-PRM path (the resource server's origin is
592-
then used)."""
599+
"""Pick the authorization server for this pass from the protected resource metadata.
600+
601+
That is the first advertised server, or None on the legacy no-PRM path (the resource
602+
server's origin is then used). When the provider is configured for one issuer, the
603+
advertised server (or that origin) has to be it.
604+
605+
Raises:
606+
OAuthFlowError: If an issuer is configured and the resource does not lead to it.
607+
"""
608+
configured = self.context.configured_issuer
593609
if prm is None:
610+
origin = self.context.get_authorization_base_url(self.context.server_url)
611+
if configured is not None and not issuers_equal(origin, configured):
612+
raise OAuthFlowError(
613+
f"No protected resource metadata, and the resource origin {origin} is not the configured "
614+
f"issuer {configured}"
615+
)
594616
return None
595-
return str(prm.authorization_servers[0])
617+
advertised = [str(url) for url in prm.authorization_servers]
618+
if configured is None:
619+
return advertised[0]
620+
for candidate in advertised:
621+
if issuers_equal(candidate, configured):
622+
return candidate
623+
raise OAuthFlowError(
624+
f"Protected resource advertises authorization servers {advertised}; none is the configured issuer "
625+
f"{configured}"
626+
)
596627

597628
def _discard_credentials_bound_elsewhere(self, expected_issuer: str) -> None:
598629
"""SEP-2352: stored credentials are bound to the issuer that registered them.

0 commit comments

Comments
 (0)