Skip to content

Commit e5b6719

Browse files
committed
[v1.x] Let pre-provisioned OAuth clients name their authorization server
Backport of the second half of #3398. 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 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. Differences from the main change: the keyword follows `scopes` (the 1.x name); there is no docs page for these providers on 1.x, so the docstrings carry the description.
1 parent 5717fa2 commit e5b6719

2 files changed

Lines changed: 274 additions & 1 deletion

File tree

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

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,58 @@
1111
import time
1212
from collections.abc import Awaitable, Callable
1313
from typing import Any, Literal
14+
from urllib.parse import urlparse
1415
from uuid import uuid4
1516

1617
import httpx
1718
import jwt
1819
from pydantic import BaseModel, Field
1920

2021
from mcp.client.auth import OAuthClientProvider, OAuthFlowError, OAuthTokenError, TokenStorage
22+
from mcp.client.auth.oauth2 import OAuthContext
23+
from mcp.client.auth.utils import issuers_match
2124
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
2225

2326

27+
def _checked_issuer(issuer: str | None) -> str | None:
28+
if issuer is not None and urlparse(issuer).scheme not in ("http", "https"):
29+
raise ValueError(f"issuer must be the authorization server's http(s) issuer URL, got {issuer!r}")
30+
return issuer
31+
32+
33+
def _preferred_authorization_server(advertised: list[str], issuer: str | None) -> str:
34+
"""The advertised server matching the configured issuer if there is one, else the first."""
35+
return next(
36+
(server for server in advertised if issuer is not None and issuers_match(server, issuer)), advertised[0]
37+
)
38+
39+
40+
def _require_metadata_for_configured_issuer(context: OAuthContext, issuer: str | None) -> None:
41+
"""With an issuer configured, a token request is only built from metadata discovered for that issuer.
42+
43+
Anything else held is dropped along with the tokens, so the next request starts discovery afresh
44+
rather than refreshing against it.
45+
"""
46+
if issuer is None:
47+
return
48+
metadata = context.oauth_metadata
49+
if metadata is not None and issuers_match(str(metadata.issuer), issuer):
50+
return
51+
context.oauth_metadata = None
52+
context.clear_tokens()
53+
if metadata is None:
54+
raise OAuthFlowError(f"No authorization server metadata discovered for configured issuer {issuer}")
55+
raise OAuthFlowError(f"Authorization server metadata issuer mismatch: {metadata.issuer} != {issuer}")
56+
57+
2458
class ClientCredentialsOAuthProvider(OAuthClientProvider):
2559
"""OAuth provider for client_credentials grant with client_id + client_secret.
2660
2761
This provider sets client_info directly, bypassing dynamic client registration.
2862
Use this when you already have client credentials (client_id and client_secret).
63+
Pass `issuer` to name the authorization server those credentials belong to: token
64+
requests are then only built from authorization server metadata for that issuer, and
65+
the flow stops if the MCP server leads anywhere else.
2966
3067
Example:
3168
```python
@@ -34,6 +71,7 @@ class ClientCredentialsOAuthProvider(OAuthClientProvider):
3471
storage=my_token_storage,
3572
client_id="my-client-id",
3673
client_secret="my-client-secret",
74+
issuer="https://auth.example.com",
3775
)
3876
```
3977
"""
@@ -46,6 +84,7 @@ def __init__(
4684
client_secret: str,
4785
token_endpoint_auth_method: Literal["client_secret_basic", "client_secret_post"] = "client_secret_basic",
4886
scopes: str | None = None,
87+
issuer: str | None = None,
4988
) -> None:
5089
"""Initialize client_credentials OAuth provider.
5190
@@ -57,6 +96,11 @@ def __init__(
5796
token_endpoint_auth_method: Authentication method for token endpoint.
5897
Either "client_secret_basic" (default) or "client_secret_post".
5998
scopes: Optional space-separated list of scopes to request.
99+
issuer: The issuer identifier of the authorization server that issued
100+
`client_id` and `client_secret`. When set, token requests are only built from
101+
discovered authorization server metadata whose `issuer` is exactly this string;
102+
otherwise the flow stops with `OAuthFlowError`. When omitted, whichever
103+
authorization server discovery yields is used.
60104
"""
61105
# Build minimal client_metadata for the base class
62106
client_metadata = OAuthClientMetadata(
@@ -66,6 +110,7 @@ def __init__(
66110
scope=scopes,
67111
)
68112
super().__init__(server_url, client_metadata, storage, None, None, 300.0)
113+
self._issuer = _checked_issuer(issuer)
69114
# Store client_info to be set during _initialize - no dynamic registration needed
70115
self._fixed_client_info = OAuthClientInformationFull(
71116
redirect_uris=None,
@@ -82,12 +127,17 @@ async def _initialize(self) -> None:
82127
self.context.client_info = self._fixed_client_info
83128
self._initialized = True
84129

130+
def _select_authorization_server(self, advertised: list[str]) -> str:
131+
return _preferred_authorization_server(advertised, self._issuer)
132+
85133
async def _perform_authorization(self) -> httpx.Request:
86134
"""Perform client_credentials authorization."""
87135
return await self._exchange_token_client_credentials()
88136

89137
async def _exchange_token_client_credentials(self) -> httpx.Request:
90138
"""Build token exchange request for client_credentials grant."""
139+
_require_metadata_for_configured_issuer(self.context, self._issuer)
140+
91141
token_data: dict[str, Any] = {
92142
"grant_type": "client_credentials",
93143
}
@@ -198,7 +248,10 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider):
198248
199249
The JWT assertion's audience MUST be the authorization server's issuer identifier
200250
(per RFC 7523bis security updates). The `assertion_provider` callback receives
201-
this audience value and must return a JWT with that audience.
251+
this audience value and must return a JWT with that audience. Pass `issuer` to name
252+
the authorization server this client is registered with: an assertion is then only
253+
minted once metadata for that issuer has been discovered, and token requests are only
254+
built from that metadata.
202255
203256
**Option 1: Pre-built JWT via Workload Identity Federation**
204257
@@ -258,6 +311,7 @@ def __init__(
258311
client_id: str,
259312
assertion_provider: Callable[[str], Awaitable[str]],
260313
scopes: str | None = None,
314+
issuer: str | None = None,
261315
) -> None:
262316
"""Initialize private_key_jwt OAuth provider.
263317
@@ -271,6 +325,11 @@ def __init__(
271325
`static_assertion_provider()` for pre-built JWTs, or provide your own
272326
callback for workload identity federation.
273327
scopes: Optional space-separated list of scopes to request.
328+
issuer: The issuer identifier of the authorization server `client_id` is
329+
registered with. When set, an assertion is only minted, and token requests
330+
are only built, once authorization server metadata whose `issuer` is exactly this
331+
string has been discovered; otherwise the flow stops with `OAuthFlowError`.
332+
When omitted, whichever authorization server discovery yields is used.
274333
"""
275334
# Build minimal client_metadata for the base class
276335
client_metadata = OAuthClientMetadata(
@@ -281,6 +340,7 @@ def __init__(
281340
)
282341
super().__init__(server_url, client_metadata, storage, None, None, 300.0)
283342
self._assertion_provider = assertion_provider
343+
self._issuer = _checked_issuer(issuer)
284344
# Store client_info to be set during _initialize - no dynamic registration needed
285345
self._fixed_client_info = OAuthClientInformationFull(
286346
redirect_uris=None,
@@ -296,6 +356,9 @@ async def _initialize(self) -> None:
296356
self.context.client_info = self._fixed_client_info
297357
self._initialized = True
298358

359+
def _select_authorization_server(self, advertised: list[str]) -> str:
360+
return _preferred_authorization_server(advertised, self._issuer)
361+
299362
async def _perform_authorization(self) -> httpx.Request:
300363
"""Perform client_credentials authorization with private_key_jwt."""
301364
return await self._exchange_token_client_credentials()
@@ -316,6 +379,8 @@ async def _add_client_authentication_jwt(self, *, token_data: dict[str, Any]) ->
316379

317380
async def _exchange_token_client_credentials(self) -> httpx.Request:
318381
"""Build token exchange request for client_credentials grant with private_key_jwt."""
382+
_require_metadata_for_configured_issuer(self.context, self._issuer)
383+
319384
token_data: dict[str, Any] = {
320385
"grant_type": "client_credentials",
321386
}

tests/client/auth/extensions/test_client_credentials.py

Lines changed: 208 additions & 0 deletions
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 httpx
35
import jwt
46
import pytest
7+
from inline_snapshot import snapshot
58
from pydantic import AnyHttpUrl, AnyUrl
69

10+
from mcp.client.auth import OAuthClientProvider, OAuthFlowError
711
from mcp.client.auth.extensions.client_credentials import (
812
ClientCredentialsOAuthProvider,
913
JWTParameters,
@@ -429,3 +433,207 @@ async def test_returns_static_token(self):
429433

430434
assert result1 == token
431435
assert result2 == token
436+
437+
438+
_SERVER_URL = "https://api.example.com/v1/mcp"
439+
_CONFIGURED_ISSUER = "https://auth.example.com"
440+
441+
442+
def _metadata_for(issuer: str) -> dict[str, str]:
443+
return {"issuer": issuer, "authorization_endpoint": f"{issuer}/authorize", "token_endpoint": f"{issuer}/token"}
444+
445+
446+
def _provider_with_issuer(kind: str, storage: MockTokenStorage, audiences: list[str]) -> OAuthClientProvider:
447+
"""A ClientCredentials ("secret") or PrivateKeyJWT ("jwt") provider configured for _CONFIGURED_ISSUER;
448+
`audiences` records every audience an assertion is minted for."""
449+
if kind == "secret":
450+
return ClientCredentialsOAuthProvider(
451+
server_url=_SERVER_URL, storage=storage, client_id="cid", client_secret="csecret", issuer=_CONFIGURED_ISSUER
452+
)
453+
454+
async def assertion_provider(audience: str) -> str:
455+
audiences.append(audience)
456+
return "signed-assertion"
457+
458+
return PrivateKeyJWTOAuthProvider(
459+
server_url=_SERVER_URL,
460+
storage=storage,
461+
client_id="cid",
462+
assertion_provider=assertion_provider,
463+
issuer=_CONFIGURED_ISSUER,
464+
)
465+
466+
467+
async def _answer_discovery(
468+
flow: AsyncGenerator[httpx.Request, httpx.Response],
469+
*,
470+
authorization_server: str | list[str] | None,
471+
metadata: dict[str, str] | None,
472+
) -> httpx.Request:
473+
"""Answer the provider's first request with a 401 and its discovery requests as described;
474+
return the request it builds once discovery is over.
475+
476+
`authorization_server` is what protected-resource metadata advertises (None: no PRM is
477+
served); `metadata` is the authorization server metadata document (None: every well-known
478+
404s).
479+
"""
480+
request = await flow.__anext__()
481+
request = await flow.asend(httpx.Response(401, request=request))
482+
while "/.well-known/oauth-protected-resource" in str(request.url):
483+
if authorization_server is None:
484+
response = httpx.Response(404, request=request)
485+
else:
486+
advertised = authorization_server if isinstance(authorization_server, list) else [authorization_server]
487+
prm = {"resource": _SERVER_URL, "authorization_servers": advertised}
488+
response = httpx.Response(200, json=prm, request=request)
489+
request = await flow.asend(response)
490+
while "/.well-known/" in str(request.url):
491+
if metadata is None:
492+
response = httpx.Response(404, request=request)
493+
else:
494+
response = httpx.Response(200, json=metadata, request=request)
495+
request = await flow.asend(response)
496+
return request
497+
498+
499+
@pytest.mark.anyio
500+
@pytest.mark.parametrize(
501+
"served_issuer", [_CONFIGURED_ISSUER, f"{_CONFIGURED_ISSUER}/"], ids=["as-configured", "root-slash"]
502+
)
503+
@pytest.mark.parametrize("kind", ["secret", "jwt"])
504+
async def test_provider_with_configured_issuer_exchanges_at_that_issuer(
505+
mock_storage: MockTokenStorage, kind: str, served_issuer: str
506+
):
507+
"""SDK-defined: with `issuer=` set and metadata discovered for that issuer (a root issuer served with
508+
its trailing slash is the same server), the token request goes to its token endpoint (positive
509+
control for the refusals below)."""
510+
audiences: list[str] = []
511+
provider = _provider_with_issuer(kind, mock_storage, audiences)
512+
flow = provider.async_auth_flow(httpx.Request("POST", _SERVER_URL))
513+
metadata = {**_metadata_for(_CONFIGURED_ISSUER), "issuer": served_issuer}
514+
515+
token_request = await _answer_discovery(flow, authorization_server=served_issuer, metadata=metadata)
516+
517+
assert (token_request.method, str(token_request.url)) == ("POST", "https://auth.example.com/token")
518+
# The SDK's URL type renders a root issuer with its trailing slash, which is the audience used.
519+
assert audiences == ([] if kind == "secret" else ["https://auth.example.com/"])
520+
await flow.aclose()
521+
522+
523+
@pytest.mark.anyio
524+
@pytest.mark.parametrize("kind", ["secret", "jwt"])
525+
async def test_provider_picks_its_configured_issuer_among_several_advertised_servers(
526+
mock_storage: MockTokenStorage, kind: str
527+
):
528+
"""SDK-defined: when the resource lists several authorization servers, the one matching `issuer=` is
529+
discovered and used even if it is not listed first."""
530+
provider = _provider_with_issuer(kind, mock_storage, [])
531+
flow = provider.async_auth_flow(httpx.Request("POST", _SERVER_URL))
532+
533+
token_request = await _answer_discovery(
534+
flow,
535+
authorization_server=["https://other-as.example.com", _CONFIGURED_ISSUER],
536+
metadata=_metadata_for(_CONFIGURED_ISSUER),
537+
)
538+
539+
assert provider.context.auth_server_url == f"{_CONFIGURED_ISSUER}/"
540+
assert str(token_request.url) == "https://auth.example.com/token"
541+
await flow.aclose()
542+
543+
544+
def test_an_issuer_that_is_not_an_http_url_is_rejected_at_construction(mock_storage: MockTokenStorage) -> None:
545+
"""SDK-defined: `issuer=` is the authorization server's issuer URL; anything else is a configuration
546+
error on both machine-to-machine providers."""
547+
with pytest.raises(ValueError) as cc_error:
548+
ClientCredentialsOAuthProvider(
549+
server_url=_SERVER_URL, storage=mock_storage, client_id="cid", client_secret="s", issuer="auth.example.com"
550+
)
551+
with pytest.raises(ValueError) as jwt_error:
552+
PrivateKeyJWTOAuthProvider(
553+
server_url=_SERVER_URL,
554+
storage=mock_storage,
555+
client_id="cid",
556+
assertion_provider=static_assertion_provider("jwt"),
557+
issuer="auth.example.com",
558+
)
559+
assert (
560+
str(cc_error.value)
561+
== str(jwt_error.value)
562+
== snapshot("issuer must be the authorization server's http(s) issuer URL, got 'auth.example.com'")
563+
)
564+
565+
566+
@pytest.mark.anyio
567+
@pytest.mark.parametrize("kind", ["secret", "jwt"])
568+
async def test_provider_refuses_metadata_for_a_different_issuer(mock_storage: MockTokenStorage, kind: str):
569+
"""SDK-defined: when discovery ends at an authorization server other than the configured `issuer`,
570+
no token request is built and no assertion is minted."""
571+
audiences: list[str] = []
572+
provider = _provider_with_issuer(kind, mock_storage, audiences)
573+
flow = provider.async_auth_flow(httpx.Request("POST", _SERVER_URL))
574+
575+
with pytest.raises(OAuthFlowError) as exc_info:
576+
await _answer_discovery(
577+
flow,
578+
authorization_server="https://other-as.example.com",
579+
metadata=_metadata_for("https://other-as.example.com"),
580+
)
581+
582+
assert str(exc_info.value) == snapshot(
583+
"Authorization server metadata issuer mismatch: https://other-as.example.com/ != https://auth.example.com"
584+
)
585+
assert audiences == []
586+
587+
588+
@pytest.mark.anyio
589+
@pytest.mark.parametrize("kind", ["secret", "jwt"])
590+
async def test_provider_refuses_to_exchange_without_metadata_when_issuer_configured(
591+
mock_storage: MockTokenStorage, kind: str
592+
):
593+
"""SDK-defined: with `issuer=` set, the 2025-03-26 default `/token` on the resource origin is not
594+
used when no authorization server metadata could be discovered."""
595+
audiences: list[str] = []
596+
provider = _provider_with_issuer(kind, mock_storage, audiences)
597+
flow = provider.async_auth_flow(httpx.Request("POST", _SERVER_URL))
598+
599+
with pytest.raises(OAuthFlowError) as exc_info:
600+
await _answer_discovery(flow, authorization_server=None, metadata=None)
601+
602+
assert str(exc_info.value) == snapshot(
603+
"No authorization server metadata discovered for configured issuer https://auth.example.com"
604+
)
605+
assert audiences == []
606+
607+
608+
@pytest.mark.anyio
609+
@pytest.mark.parametrize("kind", ["secret", "jwt"])
610+
async def test_a_refused_authorization_server_is_forgotten_so_the_next_request_rediscovers(
611+
mock_storage: MockTokenStorage, kind: str
612+
):
613+
"""SDK-defined: when the exchange is refused because discovery ended somewhere other than the
614+
configured issuer, the refused metadata and any token held are dropped; the next request goes out
615+
unauthenticated and discovery starts again, rather than a refresh being built from what was refused."""
616+
provider = _provider_with_issuer(kind, mock_storage, [])
617+
flow = provider.async_auth_flow(httpx.Request("POST", _SERVER_URL))
618+
token_request = await _answer_discovery(
619+
flow, authorization_server=_CONFIGURED_ISSUER, metadata=_metadata_for(_CONFIGURED_ISSUER)
620+
)
621+
token = {"access_token": "first", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "rt"}
622+
retried = await flow.asend(httpx.Response(200, json=token, request=token_request))
623+
with pytest.raises(StopAsyncIteration):
624+
await flow.asend(httpx.Response(200, request=retried))
625+
626+
flow = provider.async_auth_flow(httpx.Request("POST", _SERVER_URL))
627+
with pytest.raises(OAuthFlowError):
628+
await _answer_discovery(
629+
flow,
630+
authorization_server="https://other-as.example.com",
631+
metadata=_metadata_for("https://other-as.example.com"),
632+
)
633+
assert provider.context.oauth_metadata is None
634+
assert provider.context.current_tokens is None
635+
636+
flow = provider.async_auth_flow(httpx.Request("POST", _SERVER_URL))
637+
request = await flow.__anext__()
638+
assert (str(request.url), request.headers.get("Authorization")) == (_SERVER_URL, None)
639+
await flow.aclose()

0 commit comments

Comments
 (0)