Skip to content

Commit 41bcd5a

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. 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 2d97e7e commit 41bcd5a

5 files changed

Lines changed: 218 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, spelled exactly as that server's metadata states it (for an authorization server built with this SDK that is the URL with a trailing slash). 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: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,37 @@
1616
from pydantic import BaseModel, Field
1717

1818
from mcp.client.auth import OAuthClientProvider, OAuthFlowError, TokenStorage
19+
from mcp.client.auth.oauth2 import OAuthContext
20+
from mcp.client.auth.utils import validate_metadata_issuer
1921
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
2022

2123

24+
def _require_metadata_for_configured_issuer(context: OAuthContext, issuer: str | None) -> None:
25+
"""With an issuer configured, a token request is only built from metadata discovered for that issuer.
26+
27+
Anything else held is dropped along with the tokens, so the next request starts discovery afresh
28+
rather than refreshing against it.
29+
"""
30+
if issuer is None:
31+
return
32+
try:
33+
if context.oauth_metadata is None:
34+
raise OAuthFlowError(f"No authorization server metadata discovered for configured issuer {issuer}")
35+
validate_metadata_issuer(context.oauth_metadata, issuer)
36+
except OAuthFlowError:
37+
context.oauth_metadata = None
38+
context.clear_tokens()
39+
raise
40+
41+
2242
class ClientCredentialsOAuthProvider(OAuthClientProvider):
2343
"""OAuth provider for client_credentials grant with client_id + client_secret.
2444
2545
This provider sets client_info directly, bypassing dynamic client registration.
2646
Use this when you already have client credentials (client_id and client_secret).
47+
Pass `issuer` to name the authorization server those credentials belong to: token
48+
requests are then only built from authorization server metadata for that issuer, and
49+
the flow stops if the MCP server leads anywhere else.
2750
2851
Example:
2952
```python
@@ -32,6 +55,7 @@ class ClientCredentialsOAuthProvider(OAuthClientProvider):
3255
storage=my_token_storage,
3356
client_id="my-client-id",
3457
client_secret="my-client-secret",
58+
issuer="https://auth.example.com",
3559
)
3660
```
3761
"""
@@ -44,6 +68,7 @@ def __init__(
4468
client_secret: str,
4569
token_endpoint_auth_method: Literal["client_secret_basic", "client_secret_post"] = "client_secret_basic",
4670
scope: str | None = None,
71+
issuer: str | None = None,
4772
) -> None:
4873
"""Initialize client_credentials OAuth provider.
4974
@@ -55,6 +80,11 @@ def __init__(
5580
token_endpoint_auth_method: Authentication method for token endpoint.
5681
Either "client_secret_basic" (default) or "client_secret_post".
5782
scope: Optional space-separated list of scopes to request.
83+
issuer: The issuer identifier of the authorization server that issued
84+
`client_id` and `client_secret`. When set, token requests are only built from
85+
discovered authorization server metadata whose `issuer` is exactly this string;
86+
otherwise the flow stops with `OAuthFlowError`. When omitted, whichever
87+
authorization server discovery yields is used.
5888
"""
5989
# Build minimal client_metadata for the base class
6090
client_metadata = OAuthClientMetadata(
@@ -64,6 +94,7 @@ def __init__(
6494
scope=scope,
6595
)
6696
super().__init__(server_url, client_metadata, storage, None, None)
97+
self._issuer = issuer
6798
# Store client_info to be set during _initialize - no dynamic registration needed
6899
self._fixed_client_info = OAuthClientInformationFull(
69100
redirect_uris=None,
@@ -86,6 +117,8 @@ async def _perform_authorization(self) -> httpx2.Request:
86117

87118
async def _exchange_token_client_credentials(self) -> httpx2.Request:
88119
"""Build token exchange request for client_credentials grant."""
120+
_require_metadata_for_configured_issuer(self.context, self._issuer)
121+
89122
token_data: dict[str, Any] = {
90123
"grant_type": "client_credentials",
91124
}
@@ -196,7 +229,10 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider):
196229
197230
The JWT assertion's audience MUST be the authorization server's issuer identifier
198231
(per RFC 7523bis security updates). The `assertion_provider` callback receives
199-
this audience value and must return a JWT with that audience.
232+
this audience value and must return a JWT with that audience. Pass `issuer` to name
233+
the authorization server this client is registered with: an assertion is then only
234+
minted once metadata for that issuer has been discovered, and token requests are only
235+
built from that metadata.
200236
201237
**Option 1: Pre-built JWT via Workload Identity Federation**
202238
@@ -256,6 +292,7 @@ def __init__(
256292
client_id: str,
257293
assertion_provider: Callable[[str], Awaitable[str]],
258294
scope: str | None = None,
295+
issuer: str | None = None,
259296
) -> None:
260297
"""Initialize private_key_jwt OAuth provider.
261298
@@ -269,6 +306,11 @@ def __init__(
269306
`static_assertion_provider()` for pre-built JWTs, or provide your own
270307
callback for workload identity federation.
271308
scope: Optional space-separated list of scopes to request.
309+
issuer: The issuer identifier of the authorization server `client_id` is
310+
registered with. When set, an assertion is only minted, and token requests
311+
are only built, once authorization server metadata whose `issuer` is exactly this
312+
string has been discovered; otherwise the flow stops with `OAuthFlowError`.
313+
When omitted, whichever authorization server discovery yields is used.
272314
"""
273315
# Build minimal client_metadata for the base class
274316
client_metadata = OAuthClientMetadata(
@@ -279,6 +321,7 @@ def __init__(
279321
)
280322
super().__init__(server_url, client_metadata, storage, None, None)
281323
self._assertion_provider = assertion_provider
324+
self._issuer = issuer
282325
# Store client_info to be set during _initialize - no dynamic registration needed
283326
self._fixed_client_info = OAuthClientInformationFull(
284327
redirect_uris=None,
@@ -314,6 +357,8 @@ async def _add_client_authentication_jwt(self, *, token_data: dict[str, Any]) ->
314357

315358
async def _exchange_token_client_credentials(self) -> httpx2.Request:
316359
"""Build token exchange request for client_credentials grant with private_key_jwt."""
360+
_require_metadata_for_configured_issuer(self.context, self._issuer)
361+
317362
token_data: dict[str, Any] = {
318363
"grant_type": "client_credentials",
319364
}

tests/client/auth/extensions/test_client_credentials.py

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import urllib.parse
2+
from collections.abc import AsyncGenerator
23

4+
import httpx2
35
import jwt
46
import pytest
7+
from inline_snapshot import snapshot
58
from pydantic import AnyHttpUrl
69

10+
from mcp.client.auth import OAuthClientProvider, OAuthFlowError
711
from mcp.client.auth.extensions.client_credentials import (
812
ClientCredentialsOAuthProvider,
913
PrivateKeyJWTOAuthProvider,
@@ -27,7 +31,7 @@ def __init__(self):
2731
async def get_tokens(self) -> OAuthToken | None:
2832
return self._tokens
2933

30-
async def set_tokens(self, tokens: OAuthToken) -> None: # pragma: no cover
34+
async def set_tokens(self, tokens: OAuthToken) -> None:
3135
self._tokens = tokens
3236

3337
async def get_client_info(self) -> OAuthClientInformationFull | None: # pragma: no cover
@@ -325,3 +329,157 @@ async def test_returns_static_token(self):
325329

326330
assert result1 == token
327331
assert result2 == token
332+
333+
334+
_SERVER_URL = "https://api.example.com/v1/mcp"
335+
_CONFIGURED_ISSUER = "https://auth.example.com"
336+
337+
338+
def _metadata_for(issuer: str) -> dict[str, str]:
339+
return {"issuer": issuer, "authorization_endpoint": f"{issuer}/authorize", "token_endpoint": f"{issuer}/token"}
340+
341+
342+
def _provider_with_issuer(kind: str, storage: MockTokenStorage, audiences: list[str]) -> OAuthClientProvider:
343+
"""A ClientCredentials ("secret") or PrivateKeyJWT ("jwt") provider configured for _CONFIGURED_ISSUER;
344+
`audiences` records every audience an assertion is minted for."""
345+
if kind == "secret":
346+
return ClientCredentialsOAuthProvider(
347+
server_url=_SERVER_URL, storage=storage, client_id="cid", client_secret="csecret", issuer=_CONFIGURED_ISSUER
348+
)
349+
350+
async def assertion_provider(audience: str) -> str:
351+
audiences.append(audience)
352+
return "signed-assertion"
353+
354+
return PrivateKeyJWTOAuthProvider(
355+
server_url=_SERVER_URL,
356+
storage=storage,
357+
client_id="cid",
358+
assertion_provider=assertion_provider,
359+
issuer=_CONFIGURED_ISSUER,
360+
)
361+
362+
363+
async def _answer_discovery(
364+
flow: AsyncGenerator[httpx2.Request, httpx2.Response],
365+
*,
366+
authorization_server: str | None,
367+
metadata: dict[str, str] | None,
368+
) -> httpx2.Request:
369+
"""Answer the provider's first request with a 401 and its discovery requests as described;
370+
return the request it builds once discovery is over.
371+
372+
`authorization_server` is what protected-resource metadata advertises (None: no PRM is
373+
served); `metadata` is the authorization server metadata document (None: every well-known
374+
404s).
375+
"""
376+
request = await flow.__anext__()
377+
request = await flow.asend(httpx2.Response(401, request=request))
378+
while "/.well-known/oauth-protected-resource" in str(request.url):
379+
if authorization_server is None:
380+
response = httpx2.Response(404, request=request)
381+
else:
382+
prm = {"resource": _SERVER_URL, "authorization_servers": [authorization_server]}
383+
response = httpx2.Response(200, json=prm, request=request)
384+
request = await flow.asend(response)
385+
while "/.well-known/" in str(request.url):
386+
if metadata is None:
387+
response = httpx2.Response(404, request=request)
388+
else:
389+
response = httpx2.Response(200, json=metadata, request=request)
390+
request = await flow.asend(response)
391+
return request
392+
393+
394+
@pytest.mark.anyio
395+
@pytest.mark.parametrize("kind", ["secret", "jwt"])
396+
async def test_provider_with_configured_issuer_exchanges_at_that_issuer(mock_storage: MockTokenStorage, kind: str):
397+
"""SDK-defined: with `issuer=` set and metadata discovered for that issuer, the token request goes
398+
to its token endpoint (positive control for the refusals below)."""
399+
audiences: list[str] = []
400+
provider = _provider_with_issuer(kind, mock_storage, audiences)
401+
flow = provider.async_auth_flow(httpx2.Request("POST", _SERVER_URL))
402+
403+
token_request = await _answer_discovery(
404+
flow, authorization_server=_CONFIGURED_ISSUER, metadata=_metadata_for(_CONFIGURED_ISSUER)
405+
)
406+
407+
assert (token_request.method, str(token_request.url)) == ("POST", "https://auth.example.com/token")
408+
assert audiences == ([] if kind == "secret" else [_CONFIGURED_ISSUER])
409+
await flow.aclose()
410+
411+
412+
@pytest.mark.anyio
413+
@pytest.mark.parametrize("kind", ["secret", "jwt"])
414+
async def test_provider_refuses_metadata_for_a_different_issuer(mock_storage: MockTokenStorage, kind: str):
415+
"""SDK-defined: when discovery ends at an authorization server other than the configured `issuer`,
416+
no token request is built and no assertion is minted."""
417+
audiences: list[str] = []
418+
provider = _provider_with_issuer(kind, mock_storage, audiences)
419+
flow = provider.async_auth_flow(httpx2.Request("POST", _SERVER_URL))
420+
421+
with pytest.raises(OAuthFlowError) as exc_info:
422+
await _answer_discovery(
423+
flow,
424+
authorization_server="https://other-as.example.com",
425+
metadata=_metadata_for("https://other-as.example.com"),
426+
)
427+
428+
assert str(exc_info.value) == snapshot(
429+
"Authorization server metadata issuer mismatch: https://other-as.example.com != https://auth.example.com"
430+
)
431+
assert audiences == []
432+
433+
434+
@pytest.mark.anyio
435+
@pytest.mark.parametrize("kind", ["secret", "jwt"])
436+
async def test_provider_refuses_to_exchange_without_metadata_when_issuer_configured(
437+
mock_storage: MockTokenStorage, kind: str
438+
):
439+
"""SDK-defined: with `issuer=` set, the 2025-03-26 default `/token` on the resource origin is not
440+
used when no authorization server metadata could be discovered."""
441+
audiences: list[str] = []
442+
provider = _provider_with_issuer(kind, mock_storage, audiences)
443+
flow = provider.async_auth_flow(httpx2.Request("POST", _SERVER_URL))
444+
445+
with pytest.raises(OAuthFlowError) as exc_info:
446+
await _answer_discovery(flow, authorization_server=None, metadata=None)
447+
448+
assert str(exc_info.value) == snapshot(
449+
"No authorization server metadata discovered for configured issuer https://auth.example.com"
450+
)
451+
assert audiences == []
452+
453+
454+
@pytest.mark.anyio
455+
@pytest.mark.parametrize("kind", ["secret", "jwt"])
456+
async def test_a_refused_authorization_server_is_forgotten_so_the_next_request_rediscovers(
457+
mock_storage: MockTokenStorage, kind: str
458+
):
459+
"""SDK-defined: when the exchange is refused because discovery ended somewhere other than the
460+
configured issuer, the refused metadata and any token held are dropped; the next request goes out
461+
unauthenticated and discovery starts again, rather than a refresh being built from what was refused."""
462+
provider = _provider_with_issuer(kind, mock_storage, [])
463+
flow = provider.async_auth_flow(httpx2.Request("POST", _SERVER_URL))
464+
token_request = await _answer_discovery(
465+
flow, authorization_server=_CONFIGURED_ISSUER, metadata=_metadata_for(_CONFIGURED_ISSUER)
466+
)
467+
token = {"access_token": "first", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "rt"}
468+
retried = await flow.asend(httpx2.Response(200, json=token, request=token_request))
469+
with pytest.raises(StopAsyncIteration):
470+
await flow.asend(httpx2.Response(200, request=retried))
471+
472+
flow = provider.async_auth_flow(httpx2.Request("POST", _SERVER_URL))
473+
with pytest.raises(OAuthFlowError):
474+
await _answer_discovery(
475+
flow,
476+
authorization_server="https://other-as.example.com",
477+
metadata=_metadata_for("https://other-as.example.com"),
478+
)
479+
assert provider.context.oauth_metadata is None
480+
assert provider.context.current_tokens is None
481+
482+
flow = provider.async_auth_flow(httpx2.Request("POST", _SERVER_URL))
483+
request = await flow.__anext__()
484+
assert (str(request.url), request.headers.get("Authorization")) == (_SERVER_URL, None)
485+
await flow.aclose()

tests/docs_src/test_oauth_clients.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from docs_src.oauth_clients import tutorial001, tutorial002
1010
from mcp.client.auth import OAuthClientProvider, OAuthFlowError, OAuthRegistrationError, OAuthTokenError, TokenStorage
1111
from mcp.client.auth.extensions.client_credentials import (
12+
ClientCredentialsOAuthProvider,
1213
PrivateKeyJWTOAuthProvider,
1314
static_assertion_provider,
1415
)
@@ -80,6 +81,14 @@ async def test_client_credentials_provider_builds_its_own_metadata() -> None:
8081
assert metadata.scope == "user"
8182

8283

84+
@pytest.mark.parametrize("provider_class", [ClientCredentialsOAuthProvider, PrivateKeyJWTOAuthProvider])
85+
async def test_issuer_is_an_optional_keyword_on_both_machine_to_machine_providers(provider_class: type) -> None:
86+
"""tutorial002 passes `issuer=`; the page says leaving it out is allowed, on either provider."""
87+
issuer = inspect.signature(provider_class.__init__).parameters["issuer"]
88+
assert issuer.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD
89+
assert issuer.default is None
90+
91+
8392
async def test_the_two_remaining_keyword_arguments_have_defaults() -> None:
8493
"""The page names `client_metadata_url` and `validate_resource_url` as the remainder."""
8594
parameters = inspect.signature(OAuthClientProvider.__init__).parameters

0 commit comments

Comments
 (0)