Validate the iss authorization-response parameter (RFC 9207 / SEP-2468) (#2921)

This commit is contained in:
Marcelo Trylesinski 2026-06-20 16:54:18 +01:00 • committed by GitHub
commit 48cf4950dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 335 additions and 103 deletions

View file

@ -9,8 +9,10 @@ from mcp.client.auth.oauth2 import (
PKCEParameters,
TokenStorage,
)
from mcp.shared.auth import AuthorizationCodeResult
__all__ = [
"AuthorizationCodeResult",
"OAuthClientProvider",
"OAuthFlowError",
"OAuthRegistrationError",

View file

@ -18,7 +18,7 @@ import jwt
from pydantic import BaseModel, Field
from mcp.client.auth import OAuthClientProvider, OAuthFlowError, OAuthTokenError, TokenStorage
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthClientMetadata
class ClientCredentialsOAuthProvider(OAuthClientProvider):
@ -405,7 +405,7 @@ class RFC7523OAuthClientProvider(OAuthClientProvider):
client_metadata: OAuthClientMetadata,
storage: TokenStorage,
redirect_handler: Callable[[str], Awaitable[None]] | None = None,
callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None = None,
callback_handler: Callable[[], Awaitable[AuthorizationCodeResult]] | None = None,
timeout: float = 300.0,
jwt_parameters: JWTParameters | None = None,
) -> None:

View file

@ -35,9 +35,12 @@ from mcp.client.auth.utils import (
handle_token_response_scopes,
is_valid_client_metadata_url,
should_use_client_metadata_url,
validate_authorization_response_iss,
validate_metadata_issuer,
)
from mcp.client.streamable_http import MCP_PROTOCOL_VERSION
from mcp.shared.auth import (
AuthorizationCodeResult,
OAuthClientInformationFull,
OAuthClientMetadata,
OAuthMetadata,
@ -97,7 +100,7 @@ class OAuthContext:
client_metadata: OAuthClientMetadata
storage: TokenStorage
redirect_handler: Callable[[str], Awaitable[None]] | None
callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None
callback_handler: Callable[[], Awaitable[AuthorizationCodeResult]] | None
timeout: float = 300.0
client_metadata_url: str | None = None
@ -227,7 +230,7 @@ class OAuthClientProvider(httpx.Auth):
client_metadata: OAuthClientMetadata,
storage: TokenStorage,
redirect_handler: Callable[[str], Awaitable[None]] | None = None,
callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None = None,
callback_handler: Callable[[], Awaitable[AuthorizationCodeResult]] | None = None,
timeout: float = 300.0,
client_metadata_url: str | None = None,
validate_resource_url: Callable[[str, str | None], Awaitable[None]] | None = None,
@ -356,16 +359,19 @@ class OAuthClientProvider(httpx.Auth):
await self.context.redirect_handler(authorization_url)
# Wait for callback
auth_code, returned_state = await self.context.callback_handler()
result = await self.context.callback_handler()
if returned_state is None or not secrets.compare_digest(returned_state, state):
raise OAuthFlowError(f"State parameter mismatch: {returned_state} != {state}")
if result.state is None or not secrets.compare_digest(result.state, state):
raise OAuthFlowError(f"State parameter mismatch: {result.state} != {state}")
if not auth_code:
# RFC 9207: validate the authorization-response issuer
validate_authorization_response_iss(result.iss, self.context.oauth_metadata)
if not result.code:
raise OAuthFlowError("No authorization code received")
# Return auth code and code verifier for token exchange
return auth_code, pkce_params.code_verifier
return result.code, pkce_params.code_verifier
def _get_token_endpoint(self) -> str:
if self.context.oauth_metadata and self.context.oauth_metadata.token_endpoint:
@ -570,6 +576,9 @@ class OAuthClientProvider(httpx.Auth):
if not ok:
break
if ok and asm:
# SEP-2468: metadata issuer must match the discovery issuer
if self.context.auth_server_url is not None:
validate_metadata_issuer(asm, self.context.auth_server_url)
self.context.oauth_metadata = asm
break
else:

View file

@ -4,7 +4,7 @@ from urllib.parse import urljoin, urlparse
from httpx import Request, Response
from pydantic import AnyUrl, ValidationError
from mcp.client.auth import OAuthRegistrationError, OAuthTokenError
from mcp.client.auth import OAuthFlowError, OAuthRegistrationError, OAuthTokenError
from mcp.client.streamable_http import MCP_PROTOCOL_VERSION
from mcp.shared.auth import (
OAuthClientInformationFull,
@ -211,6 +211,45 @@ async def handle_auth_metadata_response(response: Response) -> tuple[bool, OAuth
return True, None
def validate_authorization_response_iss(iss: str | None, oauth_metadata: OAuthMetadata | None) -> None:
"""Validate the RFC 9207 `iss` authorization-response parameter.
Per RFC 9207 section 2.4, the client compares `iss` against the issuer of the
authorization server the request was sent to, using simple string comparison
(RFC 3986 section 6.2.1, i.e. without URL normalization), and rejects on mismatch.
A response that omits `iss` is rejected only when the server advertised support via
`authorization_response_iss_parameter_supported`.
Raises:
OAuthFlowError: If `iss` is present and does not match, or is absent when the
authorization server advertised support.
"""
expected = str(oauth_metadata.issuer) if oauth_metadata else None
if iss is not None:
if iss != expected:
raise OAuthFlowError(f"Authorization response iss mismatch: {iss} != {expected}")
return
if oauth_metadata is not None and oauth_metadata.authorization_response_iss_parameter_supported:
raise OAuthFlowError("Authorization response missing iss parameter advertised by the authorization server")
def validate_metadata_issuer(oauth_metadata: OAuthMetadata, expected_issuer: str) -> None:
"""Validate that authorization server metadata `issuer` matches the discovery issuer.
Per RFC 8414 section 3.3 / SEP-2468, the `issuer` in the metadata must match the issuer
used to construct the well-known URL, compared as a simple string (RFC 3986 section 6.2.1).
Raises:
OAuthFlowError: If the metadata issuer does not match `expected_issuer`.
"""
if str(oauth_metadata.issuer) != expected_issuer:
raise OAuthFlowError(
f"Authorization server metadata issuer mismatch: {oauth_metadata.issuer} != {expected_issuer}"
)
def create_oauth_metadata_request(url: str) -> Request:
return Request("GET", url, headers={MCP_PROTOCOL_VERSION: LATEST_PROTOCOL_VERSION})

View file

@ -22,6 +22,18 @@ class OAuthToken(BaseModel):
return v # pragma: no cover
class AuthorizationCodeResult(BaseModel):
"""Authorization-code-grant redirect parameters returned by a callback handler.
`iss` carries the RFC 9207 authorization-response issuer when the authorization server
includes it in the redirect; the client validates it against the expected issuer.
"""
code: str
state: str | None = None
iss: str | None = None
class InvalidScopeError(Exception):
def __init__(self, message: str):
self.message = message
@ -149,6 +161,7 @@ class OAuthMetadata(BaseModel):
introspection_endpoint_auth_signing_alg_values_supported: list[str] | None = None
code_challenge_methods_supported: list[str] | None = None
client_id_metadata_document_supported: bool | None = None
authorization_response_iss_parameter_supported: bool | None = None
class ProtectedResourceMetadata(BaseModel):