Validate the iss authorization-response parameter (RFC 9207 / SEP-2468) (#2921)
This commit is contained in:
parent
b7a5bffed0
commit
48cf4950dc
5 changed files with 335 additions and 103 deletions
|
|
@ -9,8 +9,10 @@ from mcp.client.auth.oauth2 import (
|
|||
PKCEParameters,
|
||||
TokenStorage,
|
||||
)
|
||||
from mcp.shared.auth import AuthorizationCodeResult
|
||||
|
||||
__all__ = [
|
||||
"AuthorizationCodeResult",
|
||||
"OAuthClientProvider",
|
||||
"OAuthFlowError",
|
||||
"OAuthRegistrationError",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in a new issue