Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/actions/conformance/expected-failures.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
# Known conformance test failures for v1.x
# These are tracked and should be removed as they're fixed.
server: []
client: []
client:
# The pinned harness (0.1.13) serves authorization server metadata whose `issuer`
# omits the tenant path its resource metadata advertises (`/tenant1`), so a client
# that checks RFC 8414 section 3.3 refuses it. The mock includes the path from
# conformance 0.1.15 (modelcontextprotocol/conformance#152); drop these two entries
# when the pin moves past it.
- auth/metadata-var2
- auth/metadata-var3
86 changes: 83 additions & 3 deletions src/mcp/client/auth/extensions/client_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,70 @@
"""

import time
import warnings
from collections.abc import Awaitable, Callable
from typing import Any, Literal
from urllib.parse import urlparse
from uuid import uuid4

import httpx
import jwt
from pydantic import BaseModel, Field

from mcp.client.auth import OAuthClientProvider, OAuthFlowError, OAuthTokenError, TokenStorage
from mcp.client.auth.oauth2 import OAuthContext
from mcp.client.auth.utils import issuers_match
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata


def _checked_issuer(issuer: str | None) -> str | None:
if issuer is None:
warnings.warn(
"Omitting `issuer` is deprecated and it will be required in 3.0. Without it, the MCP server "
"decides which authorization server receives this client's credentials; pass "
"issuer=<your authorization server's issuer URL> so they are only ever sent there.",
DeprecationWarning,
stacklevel=3,
)
return None
if urlparse(issuer).scheme not in ("http", "https"):
raise ValueError(f"issuer must be the authorization server's http(s) issuer URL, got {issuer!r}")
return issuer
Comment thread
maxisbey marked this conversation as resolved.


def _preferred_authorization_server(advertised: list[str], issuer: str | None) -> str:
"""The advertised server matching the configured issuer if there is one, else the first."""
return next(
(server for server in advertised if issuer is not None and issuers_match(server, issuer)), advertised[0]
)


def _require_metadata_for_configured_issuer(context: OAuthContext, issuer: str | None) -> None:
"""With an issuer configured, a token request is only built from metadata discovered for that issuer.

Anything else held is dropped along with the tokens, so the next request starts discovery afresh
rather than refreshing against it.
"""
if issuer is None:
return
metadata = context.oauth_metadata
if metadata is not None and issuers_match(str(metadata.issuer), issuer):
Comment thread
maxisbey marked this conversation as resolved.
return
context.oauth_metadata = None
context.clear_tokens()
if metadata is None:
raise OAuthFlowError(f"No authorization server metadata discovered for configured issuer {issuer}")
raise OAuthFlowError(f"Authorization server metadata issuer mismatch: {metadata.issuer} != {issuer}")


class ClientCredentialsOAuthProvider(OAuthClientProvider):
"""OAuth provider for client_credentials grant with client_id + client_secret.

This provider sets client_info directly, bypassing dynamic client registration.
Use this when you already have client credentials (client_id and client_secret).
Pass `issuer` to name the authorization server those credentials belong to: token
requests are then only built from authorization server metadata for that issuer, and
the flow stops if the MCP server leads anywhere else.

Example:
```python
Expand All @@ -34,6 +81,7 @@ class ClientCredentialsOAuthProvider(OAuthClientProvider):
storage=my_token_storage,
client_id="my-client-id",
client_secret="my-client-secret",
issuer="https://auth.example.com",
)
```
"""
Expand All @@ -46,6 +94,7 @@ def __init__(
client_secret: str,
token_endpoint_auth_method: Literal["client_secret_basic", "client_secret_post"] = "client_secret_basic",
scopes: str | None = None,
issuer: str | None = None,
) -> None:
"""Initialize client_credentials OAuth provider.

Expand All @@ -57,6 +106,12 @@ def __init__(
token_endpoint_auth_method: Authentication method for token endpoint.
Either "client_secret_basic" (default) or "client_secret_post".
scopes: Optional space-separated list of scopes to request.
issuer: The issuer identifier of the authorization server that issued
`client_id` and `client_secret`. When set, token requests are only built from
discovered authorization server metadata whose `issuer` is exactly this string;
otherwise the flow stops with `OAuthFlowError`. Omitting it is deprecated
(`DeprecationWarning`) and it will be required in 3.0; until then, whichever
authorization server discovery yields is used.
Comment thread
maxisbey marked this conversation as resolved.
"""
# Build minimal client_metadata for the base class
client_metadata = OAuthClientMetadata(
Expand All @@ -66,6 +121,7 @@ def __init__(
scope=scopes,
)
super().__init__(server_url, client_metadata, storage, None, None, 300.0)
self._issuer = _checked_issuer(issuer)
# Store client_info to be set during _initialize - no dynamic registration needed
self._fixed_client_info = OAuthClientInformationFull(
redirect_uris=None,
Expand All @@ -82,12 +138,17 @@ async def _initialize(self) -> None:
self.context.client_info = self._fixed_client_info
self._initialized = True

def _select_authorization_server(self, advertised: list[str]) -> str:
return _preferred_authorization_server(advertised, self._issuer)

async def _perform_authorization(self) -> httpx.Request:
"""Perform client_credentials authorization."""
return await self._exchange_token_client_credentials()

async def _exchange_token_client_credentials(self) -> httpx.Request:
"""Build token exchange request for client_credentials grant."""
_require_metadata_for_configured_issuer(self.context, self._issuer)
Comment thread
maxisbey marked this conversation as resolved.

token_data: dict[str, Any] = {
"grant_type": "client_credentials",
}
Expand Down Expand Up @@ -120,6 +181,7 @@ def static_assertion_provider(token: str) -> Callable[[str], Awaitable[str]]:
storage=my_token_storage,
client_id="my-client-id",
assertion_provider=static_assertion_provider(my_prebuilt_jwt),
issuer="https://auth.example.com",
)
```

Expand Down Expand Up @@ -154,6 +216,7 @@ class SignedJWTParameters(BaseModel):
storage=my_token_storage,
client_id="my-client-id",
assertion_provider=jwt_params.create_assertion_provider(),
issuer="https://auth.example.com",
)
```
"""
Expand Down Expand Up @@ -198,7 +261,10 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider):

The JWT assertion's audience MUST be the authorization server's issuer identifier
(per RFC 7523bis security updates). The `assertion_provider` callback receives
this audience value and must return a JWT with that audience.
this audience value and must return a JWT with that audience. Pass `issuer` to name
the authorization server this client is registered with: an assertion is then only
minted once metadata for that issuer has been discovered, and token requests are only
built from that metadata.

**Option 1: Pre-built JWT via Workload Identity Federation**

Expand All @@ -216,6 +282,7 @@ async def get_workload_identity_token(audience: str) -> str:
storage=my_token_storage,
client_id="my-client-id",
assertion_provider=get_workload_identity_token,
issuer="https://auth.example.com",
)
```

Expand All @@ -229,6 +296,7 @@ async def get_workload_identity_token(audience: str) -> str:
storage=my_token_storage,
client_id="my-client-id",
assertion_provider=static_assertion_provider(my_prebuilt_jwt),
issuer="https://auth.example.com",
)
```

Expand All @@ -247,6 +315,7 @@ async def get_workload_identity_token(audience: str) -> str:
storage=my_token_storage,
client_id="my-client-id",
assertion_provider=jwt_params.create_assertion_provider(),
issuer="https://auth.example.com",
)
```
"""
Expand All @@ -258,6 +327,7 @@ def __init__(
client_id: str,
assertion_provider: Callable[[str], Awaitable[str]],
scopes: str | None = None,
issuer: str | None = None,
) -> None:
"""Initialize private_key_jwt OAuth provider.

Expand All @@ -271,6 +341,12 @@ def __init__(
`static_assertion_provider()` for pre-built JWTs, or provide your own
callback for workload identity federation.
scopes: Optional space-separated list of scopes to request.
issuer: The issuer identifier of the authorization server `client_id` is
registered with. When set, an assertion is only minted, and token requests
are only built, once authorization server metadata whose `issuer` is exactly this
string has been discovered; otherwise the flow stops with `OAuthFlowError`.
Omitting it is deprecated (`DeprecationWarning`) and it will be required in
3.0; until then, whichever authorization server discovery yields is used.
"""
# Build minimal client_metadata for the base class
client_metadata = OAuthClientMetadata(
Expand All @@ -281,6 +357,7 @@ def __init__(
)
super().__init__(server_url, client_metadata, storage, None, None, 300.0)
self._assertion_provider = assertion_provider
self._issuer = _checked_issuer(issuer)
# Store client_info to be set during _initialize - no dynamic registration needed
self._fixed_client_info = OAuthClientInformationFull(
redirect_uris=None,
Expand All @@ -296,6 +373,9 @@ async def _initialize(self) -> None:
self.context.client_info = self._fixed_client_info
self._initialized = True

def _select_authorization_server(self, advertised: list[str]) -> str:
return _preferred_authorization_server(advertised, self._issuer)

async def _perform_authorization(self) -> httpx.Request:
"""Perform client_credentials authorization with private_key_jwt."""
return await self._exchange_token_client_credentials()
Expand All @@ -316,6 +396,8 @@ async def _add_client_authentication_jwt(self, *, token_data: dict[str, Any]) ->

async def _exchange_token_client_credentials(self) -> httpx.Request:
"""Build token exchange request for client_credentials grant with private_key_jwt."""
_require_metadata_for_configured_issuer(self.context, self._issuer)

token_data: dict[str, Any] = {
"grant_type": "client_credentials",
}
Expand Down Expand Up @@ -409,8 +491,6 @@ def __init__(
timeout: float = 300.0,
jwt_parameters: JWTParameters | None = None,
) -> None:
import warnings

warnings.warn(
"RFC7523OAuthClientProvider is deprecated. Use ClientCredentialsOAuthProvider "
"or PrivateKeyJWTOAuthProvider instead.",
Expand Down
Loading
Loading