Skip to content

Commit 7163d82

Browse files
authored
Remove the deprecated RFC7523OAuthClientProvider (#3169)
1 parent 1963af5 commit 7163d82

5 files changed

Lines changed: 38 additions & 319 deletions

File tree

docs/migration.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1920,6 +1920,33 @@ The WebSocket transport has been removed: `mcp.client.websocket.websocket_client
19201920

19211921
## OAuth and server auth
19221922

1923+
### `RFC7523OAuthClientProvider` and `JWTParameters` removed
1924+
1925+
`RFC7523OAuthClientProvider` (deprecated since 1.23.0) and its `JWTParameters` model have been
1926+
removed from `mcp.client.auth.extensions.client_credentials`. The provider implemented the
1927+
[RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) §2.1 `jwt-bearer` *authorization grant*
1928+
with an SDK-minted or prebuilt JWT, which no MCP auth extension specifies. Replace it with the
1929+
purpose-built provider for the flow you actually run:
1930+
1931+
- Machine-to-machine with a client secret
1932+
([`io.modelcontextprotocol/oauth-client-credentials`](https://modelcontextprotocol.io/extensions/auth/oauth-client-credentials)):
1933+
`ClientCredentialsOAuthProvider(server_url=..., storage=..., client_id=..., client_secret=...)`.
1934+
- Machine-to-machine authenticating with a JWT instead of a secret (same extension, RFC 7523 §2.2
1935+
`private_key_jwt` client authentication on the `client_credentials` grant, which is the mode the
1936+
extension actually specifies for JWTs): `PrivateKeyJWTOAuthProvider(server_url=...,
1937+
storage=..., client_id=..., assertion_provider=...)`. Build the assertion with
1938+
`SignedJWTParameters(issuer=..., subject=..., signing_key=...).create_assertion_provider()`
1939+
(replaces `JWTParameters` signing fields), or wrap a prebuilt JWT with
1940+
`static_assertion_provider(token)` (replaces `JWTParameters(assertion=...)`).
1941+
- Presenting an enterprise ID-JAG under the `jwt-bearer` grant
1942+
([SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990)):
1943+
`IdentityAssertionOAuthProvider` in `mcp.client.auth.extensions.identity_assertion`.
1944+
1945+
The provider's third mode — the interactive `authorization_code` flow with `private_key_jwt`
1946+
client authentication on the token exchange — has no replacement and is intentionally dropped; it
1947+
was never exercised by the test suite and no MCP auth extension specifies it. If you depended on
1948+
it, open an issue describing the deployment.
1949+
19231950
### OAuth metadata URLs no longer gain a trailing slash
19241951

19251952
`OAuthMetadata`, `ProtectedResourceMetadata`, and `OAuthClientMetadata` now set

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

Lines changed: 2 additions & 155 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,9 @@
44
- ClientCredentialsOAuthProvider: For client_credentials with client_id + client_secret
55
- PrivateKeyJWTOAuthProvider: For client_credentials with private_key_jwt authentication
66
(typically using a pre-built JWT from workload identity federation)
7-
- RFC7523OAuthClientProvider: For jwt-bearer grant (RFC 7523 Section 2.1)
87
"""
98

109
import time
11-
import warnings
1210
from collections.abc import Awaitable, Callable
1311
from typing import Any, Literal
1412
from uuid import uuid4
@@ -17,9 +15,8 @@
1715
import jwt
1816
from pydantic import BaseModel, Field
1917

20-
from mcp.client.auth import OAuthClientProvider, OAuthFlowError, OAuthTokenError, TokenStorage
21-
from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthClientMetadata
22-
from mcp.shared.exceptions import MCPDeprecationWarning
18+
from mcp.client.auth import OAuthClientProvider, OAuthFlowError, TokenStorage
19+
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
2320

2421

2522
class ClientCredentialsOAuthProvider(OAuthClientProvider):
@@ -334,153 +331,3 @@ async def _exchange_token_client_credentials(self) -> httpx2.Request:
334331

335332
token_url = self._get_token_endpoint()
336333
return httpx2.Request("POST", token_url, data=token_data, headers=headers)
337-
338-
339-
class JWTParameters(BaseModel):
340-
"""JWT parameters."""
341-
342-
assertion: str | None = Field(
343-
default=None,
344-
description="JWT assertion for JWT authentication. "
345-
"Will be used instead of generating a new assertion if provided.",
346-
)
347-
348-
issuer: str | None = Field(default=None, description="Issuer for JWT assertions.")
349-
subject: str | None = Field(default=None, description="Subject identifier for JWT assertions.")
350-
audience: str | None = Field(default=None, description="Audience for JWT assertions.")
351-
claims: dict[str, Any] | None = Field(default=None, description="Additional claims for JWT assertions.")
352-
jwt_signing_algorithm: str | None = Field(default="RS256", description="Algorithm for signing JWT assertions.")
353-
jwt_signing_key: str | None = Field(default=None, description="Private key for JWT signing.")
354-
jwt_lifetime_seconds: int = Field(default=300, description="Lifetime of generated JWT in seconds.")
355-
356-
def to_assertion(self, with_audience_fallback: str | None = None) -> str:
357-
if self.assertion is not None:
358-
# Prebuilt JWT (e.g. acquired out-of-band)
359-
assertion = self.assertion
360-
else:
361-
if not self.jwt_signing_key:
362-
raise OAuthFlowError("Missing signing key for JWT bearer grant") # pragma: no cover
363-
if not self.issuer:
364-
raise OAuthFlowError("Missing issuer for JWT bearer grant") # pragma: no cover
365-
if not self.subject:
366-
raise OAuthFlowError("Missing subject for JWT bearer grant") # pragma: no cover
367-
368-
audience = self.audience if self.audience else with_audience_fallback
369-
if not audience:
370-
raise OAuthFlowError("Missing audience for JWT bearer grant") # pragma: no cover
371-
372-
now = int(time.time())
373-
claims: dict[str, Any] = {
374-
"iss": self.issuer,
375-
"sub": self.subject,
376-
"aud": audience,
377-
"exp": now + self.jwt_lifetime_seconds,
378-
"iat": now,
379-
"jti": str(uuid4()),
380-
}
381-
claims.update(self.claims or {})
382-
383-
assertion = jwt.encode(
384-
claims,
385-
self.jwt_signing_key,
386-
algorithm=self.jwt_signing_algorithm or "RS256",
387-
)
388-
return assertion
389-
390-
391-
class RFC7523OAuthClientProvider(OAuthClientProvider):
392-
"""OAuth client provider for RFC 7523 jwt-bearer grant.
393-
394-
.. deprecated::
395-
Use :class:`ClientCredentialsOAuthProvider` for client_credentials with
396-
client_id + client_secret, or :class:`PrivateKeyJWTOAuthProvider` for
397-
client_credentials with private_key_jwt authentication instead.
398-
399-
This provider supports the jwt-bearer authorization grant (RFC 7523 Section 2.1)
400-
where the JWT itself is the authorization grant.
401-
"""
402-
403-
def __init__(
404-
self,
405-
server_url: str,
406-
client_metadata: OAuthClientMetadata,
407-
storage: TokenStorage,
408-
redirect_handler: Callable[[str], Awaitable[None]] | None = None,
409-
callback_handler: Callable[[], Awaitable[AuthorizationCodeResult]] | None = None,
410-
timeout: float = 300.0,
411-
jwt_parameters: JWTParameters | None = None,
412-
) -> None:
413-
warnings.warn(
414-
"RFC7523OAuthClientProvider is deprecated. Use ClientCredentialsOAuthProvider "
415-
"or PrivateKeyJWTOAuthProvider instead.",
416-
MCPDeprecationWarning,
417-
stacklevel=2,
418-
)
419-
super().__init__(server_url, client_metadata, storage, redirect_handler, callback_handler, timeout)
420-
self.jwt_parameters = jwt_parameters
421-
422-
async def _exchange_token_authorization_code(
423-
self, auth_code: str, code_verifier: str, *, token_data: dict[str, Any] | None = None
424-
) -> httpx2.Request: # pragma: no cover
425-
"""Build token exchange request for authorization_code flow."""
426-
token_data = token_data or {}
427-
if self.context.client_metadata.token_endpoint_auth_method == "private_key_jwt":
428-
self._add_client_authentication_jwt(token_data=token_data)
429-
return await super()._exchange_token_authorization_code(auth_code, code_verifier, token_data=token_data)
430-
431-
async def _perform_authorization(self) -> httpx2.Request: # pragma: no cover
432-
"""Perform the authorization flow."""
433-
if "urn:ietf:params:oauth:grant-type:jwt-bearer" in self.context.client_metadata.grant_types:
434-
token_request = await self._exchange_token_jwt_bearer()
435-
return token_request
436-
else:
437-
return await super()._perform_authorization()
438-
439-
def _add_client_authentication_jwt(self, *, token_data: dict[str, Any]): # pragma: no cover
440-
"""Add JWT assertion for client authentication to token endpoint parameters."""
441-
if not self.jwt_parameters:
442-
raise OAuthTokenError("Missing JWT parameters for private_key_jwt flow")
443-
if not self.context.oauth_metadata:
444-
raise OAuthTokenError("Missing OAuth metadata for private_key_jwt flow")
445-
446-
# We need to set the audience to the issuer identifier of the authorization server
447-
# https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rfc7523bis-01#name-updates-to-rfc-7523
448-
issuer = str(self.context.oauth_metadata.issuer)
449-
assertion = self.jwt_parameters.to_assertion(with_audience_fallback=issuer)
450-
451-
# When using private_key_jwt, in a client_credentials flow, we use RFC 7523 Section 2.2
452-
token_data["client_assertion"] = assertion
453-
token_data["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
454-
# We need to set the audience to the resource server, the audience is different from the one in claims
455-
# it represents the resource server that will validate the token
456-
token_data["audience"] = self.context.get_resource_url()
457-
458-
async def _exchange_token_jwt_bearer(self) -> httpx2.Request:
459-
"""Build token exchange request for JWT bearer grant."""
460-
if not self.context.client_info:
461-
raise OAuthFlowError("Missing client info") # pragma: no cover
462-
if not self.jwt_parameters:
463-
raise OAuthFlowError("Missing JWT parameters") # pragma: no cover
464-
if not self.context.oauth_metadata:
465-
raise OAuthTokenError("Missing OAuth metadata") # pragma: no cover
466-
467-
# We need to set the audience to the issuer identifier of the authorization server
468-
# https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rfc7523bis-01#name-updates-to-rfc-7523
469-
issuer = str(self.context.oauth_metadata.issuer)
470-
assertion = self.jwt_parameters.to_assertion(with_audience_fallback=issuer)
471-
472-
token_data = {
473-
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
474-
"assertion": assertion,
475-
}
476-
477-
if self.context.should_include_resource_param(self.context.protocol_version): # pragma: no branch
478-
token_data["resource"] = self.context.get_resource_url()
479-
480-
if self.context.client_metadata.scope: # pragma: no branch
481-
token_data["scope"] = self.context.client_metadata.scope
482-
483-
token_url = self._get_token_endpoint()
484-
return httpx2.Request(
485-
"POST", token_url, data=token_data, headers={"Content-Type": "application/x-www-form-urlencoded"}
486-
)

src/mcp/client/auth/oauth2.py

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -383,26 +383,21 @@ def _get_token_endpoint(self) -> str:
383383
token_url = urljoin(auth_base_url, "/token")
384384
return token_url
385385

386-
async def _exchange_token_authorization_code(
387-
self, auth_code: str, code_verifier: str, *, token_data: dict[str, Any] | None = {}
388-
) -> httpx2.Request:
386+
async def _exchange_token_authorization_code(self, auth_code: str, code_verifier: str) -> httpx2.Request:
389387
"""Build token exchange request for authorization_code flow."""
390388
if self.context.client_metadata.redirect_uris is None:
391389
raise OAuthFlowError("No redirect URIs provided for authorization code grant") # pragma: no cover
392390
if not self.context.client_info:
393391
raise OAuthFlowError("Missing client info") # pragma: no cover
394392

395393
token_url = self._get_token_endpoint()
396-
token_data = token_data or {}
397-
token_data.update(
398-
{
399-
"grant_type": "authorization_code",
400-
"code": auth_code,
401-
"redirect_uri": str(self.context.client_metadata.redirect_uris[0]),
402-
"client_id": self.context.client_info.client_id,
403-
"code_verifier": code_verifier,
404-
}
405-
)
394+
token_data: dict[str, Any] = {
395+
"grant_type": "authorization_code",
396+
"code": auth_code,
397+
"redirect_uri": str(self.context.client_metadata.redirect_uris[0]),
398+
"client_id": self.context.client_info.client_id,
399+
"code_verifier": code_verifier,
400+
}
406401

407402
# Only include resource param if conditions are met
408403
if self.context.should_include_resource_param(self.context.protocol_version):

0 commit comments

Comments
 (0)