Validate the iss authorization-response parameter (RFC 9207 / SEP-2468) (#2921)
This commit is contained in:
parent
b7a5bffed0
commit
48cf4950dc
20 changed files with 335 additions and 103 deletions
15
.github/actions/conformance/client.py
vendored
15
.github/actions/conformance/client.py
vendored
|
|
@ -43,7 +43,7 @@ from mcp.client.auth.extensions.client_credentials import (
|
||||||
)
|
)
|
||||||
from mcp.client.context import ClientRequestContext
|
from mcp.client.context import ClientRequestContext
|
||||||
from mcp.client.streamable_http import streamable_http_client
|
from mcp.client.streamable_http import streamable_http_client
|
||||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
|
from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
|
||||||
|
|
||||||
# Set up logging to stderr (stdout is for conformance test output)
|
# Set up logging to stderr (stdout is for conformance test output)
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
|
|
@ -119,6 +119,7 @@ class ConformanceOAuthCallbackHandler:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._auth_code: str | None = None
|
self._auth_code: str | None = None
|
||||||
self._state: str | None = None
|
self._state: str | None = None
|
||||||
|
self._iss: str | None = None
|
||||||
|
|
||||||
async def handle_redirect(self, authorization_url: str) -> None:
|
async def handle_redirect(self, authorization_url: str) -> None:
|
||||||
"""Fetch the authorization URL and extract the auth code from the redirect."""
|
"""Fetch the authorization URL and extract the auth code from the redirect."""
|
||||||
|
|
@ -140,6 +141,8 @@ class ConformanceOAuthCallbackHandler:
|
||||||
self._auth_code = query_params["code"][0]
|
self._auth_code = query_params["code"][0]
|
||||||
state_values = query_params.get("state")
|
state_values = query_params.get("state")
|
||||||
self._state = state_values[0] if state_values else None
|
self._state = state_values[0] if state_values else None
|
||||||
|
iss_values = query_params.get("iss")
|
||||||
|
self._iss = iss_values[0] if iss_values else None
|
||||||
logger.debug(f"Got auth code from redirect: {self._auth_code[:10]}...")
|
logger.debug(f"Got auth code from redirect: {self._auth_code[:10]}...")
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
|
|
@ -149,15 +152,15 @@ class ConformanceOAuthCallbackHandler:
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(f"Expected redirect response, got {response.status_code} from {authorization_url}")
|
raise RuntimeError(f"Expected redirect response, got {response.status_code} from {authorization_url}")
|
||||||
|
|
||||||
async def handle_callback(self) -> tuple[str, str | None]:
|
async def handle_callback(self) -> AuthorizationCodeResult:
|
||||||
"""Return the captured auth code and state."""
|
"""Return the captured auth code, state, and iss."""
|
||||||
if self._auth_code is None:
|
if self._auth_code is None:
|
||||||
raise RuntimeError("No authorization code available - was handle_redirect called?")
|
raise RuntimeError("No authorization code available - was handle_redirect called?")
|
||||||
auth_code = self._auth_code
|
result = AuthorizationCodeResult(code=self._auth_code, state=self._state, iss=self._iss)
|
||||||
state = self._state
|
|
||||||
self._auth_code = None
|
self._auth_code = None
|
||||||
self._state = None
|
self._state = None
|
||||||
return auth_code, state
|
self._iss = None
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
# --- Scenario Handlers ---
|
# --- Scenario Handlers ---
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,15 @@ client:
|
||||||
- auth/token-endpoint-auth-post
|
- auth/token-endpoint-auth-post
|
||||||
- auth/token-endpoint-auth-none
|
- auth/token-endpoint-auth-none
|
||||||
- auth/offline-access-not-supported
|
- auth/offline-access-not-supported
|
||||||
|
# SEP-2468 (authorization response iss parameter) is implemented, but these
|
||||||
|
# 2026-introduced scenarios reach DCR and so still fail the application_type
|
||||||
|
# check above; they unblock with SEP-837, not SEP-2468.
|
||||||
|
- auth/iss-supported
|
||||||
|
- auth/iss-not-advertised
|
||||||
|
- auth/iss-supported-missing
|
||||||
|
- auth/iss-wrong-issuer
|
||||||
|
- auth/iss-unexpected
|
||||||
|
- auth/iss-normalized
|
||||||
|
|
||||||
# --- Auth scenarios cut short by the 2026 connection lifecycle ---
|
# --- Auth scenarios cut short by the 2026 connection lifecycle ---
|
||||||
# The auth fixture flow drives the 2025 stateful lifecycle; the 2026-mode
|
# The auth fixture flow drives the 2025 stateful lifecycle; the 2026-mode
|
||||||
|
|
@ -65,14 +74,6 @@ client:
|
||||||
- http-invalid-tool-headers
|
- http-invalid-tool-headers
|
||||||
# SEP-2106 (JSON Schema $ref handling): client still dereferences network $refs.
|
# SEP-2106 (JSON Schema $ref handling): client still dereferences network $refs.
|
||||||
- json-schema-ref-no-deref
|
- json-schema-ref-no-deref
|
||||||
# SEP-2468 (authorization response iss parameter): not implemented in the client.
|
|
||||||
- auth/iss-supported
|
|
||||||
- auth/iss-not-advertised
|
|
||||||
- auth/iss-supported-missing
|
|
||||||
- auth/iss-wrong-issuer
|
|
||||||
- auth/iss-unexpected
|
|
||||||
- auth/iss-normalized
|
|
||||||
- auth/metadata-issuer-mismatch
|
|
||||||
# SEP-2352 (authorization server migration): client does not re-register when
|
# SEP-2352 (authorization server migration): client does not re-register when
|
||||||
# PRM authorization_servers changes.
|
# PRM authorization_servers changes.
|
||||||
- auth/authorization-server-migration
|
- auth/authorization-server-migration
|
||||||
|
|
|
||||||
|
|
@ -24,20 +24,21 @@ client:
|
||||||
- http-invalid-tool-headers
|
- http-invalid-tool-headers
|
||||||
# SEP-2106 (JSON Schema $ref handling): client still dereferences network $refs.
|
# SEP-2106 (JSON Schema $ref handling): client still dereferences network $refs.
|
||||||
- json-schema-ref-no-deref
|
- json-schema-ref-no-deref
|
||||||
# SEP-2468 (authorization response iss parameter): not implemented in the client.
|
# SEP-2352 (authorization server migration): client does not re-register when
|
||||||
|
# PRM authorization_servers changes.
|
||||||
|
- auth/authorization-server-migration
|
||||||
|
# SEP-837 (application_type during DCR): the check fires on every non-legacy
|
||||||
|
# spec version (the default LATEST is 2026-07-28). The client omits
|
||||||
|
# application_type during Dynamic Client Registration, so every scenario that
|
||||||
|
# reaches DCR fails it. SEP-2468 iss validation is implemented, so these now
|
||||||
|
# fail only on the application_type check, not on iss.
|
||||||
|
- auth/offline-access-not-supported
|
||||||
- auth/iss-supported
|
- auth/iss-supported
|
||||||
- auth/iss-not-advertised
|
- auth/iss-not-advertised
|
||||||
- auth/iss-supported-missing
|
- auth/iss-supported-missing
|
||||||
- auth/iss-wrong-issuer
|
- auth/iss-wrong-issuer
|
||||||
- auth/iss-unexpected
|
- auth/iss-unexpected
|
||||||
- auth/iss-normalized
|
- auth/iss-normalized
|
||||||
- auth/metadata-issuer-mismatch
|
|
||||||
# SEP-2352 (authorization server migration): client does not re-register when
|
|
||||||
# PRM authorization_servers changes.
|
|
||||||
- auth/authorization-server-migration
|
|
||||||
# SEP-837 (application_type during DCR): the check only fires on draft-version
|
|
||||||
# runs; this draft scenario is the one place the client still hits it.
|
|
||||||
- auth/offline-access-not-supported
|
|
||||||
|
|
||||||
# --- Pre-existing scenarios that fail on checks added after conformance 0.1.15 ---
|
# --- Pre-existing scenarios that fail on checks added after conformance 0.1.15 ---
|
||||||
# SEP-2350 (scope step-up): WARNING-only; the expected-failures evaluator
|
# SEP-2350 (scope step-up): WARNING-only; the expected-failures evaluator
|
||||||
|
|
|
||||||
10
README.v2.md
10
README.v2.md
|
|
@ -2323,7 +2323,7 @@ import httpx
|
||||||
from pydantic import AnyUrl
|
from pydantic import AnyUrl
|
||||||
|
|
||||||
from mcp import ClientSession
|
from mcp import ClientSession
|
||||||
from mcp.client.auth import OAuthClientProvider, TokenStorage
|
from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider, TokenStorage
|
||||||
from mcp.client.streamable_http import streamable_http_client
|
from mcp.client.streamable_http import streamable_http_client
|
||||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
|
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
|
||||||
|
|
||||||
|
|
@ -2356,10 +2356,14 @@ async def handle_redirect(auth_url: str) -> None:
|
||||||
print(f"Visit: {auth_url}")
|
print(f"Visit: {auth_url}")
|
||||||
|
|
||||||
|
|
||||||
async def handle_callback() -> tuple[str, str | None]:
|
async def handle_callback() -> AuthorizationCodeResult:
|
||||||
callback_url = input("Paste callback URL: ")
|
callback_url = input("Paste callback URL: ")
|
||||||
params = parse_qs(urlparse(callback_url).query)
|
params = parse_qs(urlparse(callback_url).query)
|
||||||
return params["code"][0], params.get("state", [None])[0]
|
return AuthorizationCodeResult(
|
||||||
|
code=params["code"][0],
|
||||||
|
state=params.get("state", [None])[0],
|
||||||
|
iss=params.get("iss", [None])[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,35 @@ async with http_client:
|
||||||
|
|
||||||
v1's internal client set `follow_redirects=True`; set it explicitly when supplying your own `httpx.AsyncClient` to preserve that behavior.
|
v1's internal client set `follow_redirects=True`; set it explicitly when supplying your own `httpx.AsyncClient` to preserve that behavior.
|
||||||
|
|
||||||
|
### OAuth `callback_handler` returns `AuthorizationCodeResult`
|
||||||
|
|
||||||
|
The `callback_handler` passed to `OAuthClientProvider` now returns an `AuthorizationCodeResult` instead of a `tuple[str, str | None]` of `(code, state)`. The new object adds an `iss` field so the client can validate the RFC 9207 authorization-response issuer (SEP-2468): when the redirect carries an `iss` query parameter it must match the authorization server's issuer, and a missing `iss` is rejected when the server advertised `authorization_response_iss_parameter_supported`.
|
||||||
|
|
||||||
|
**Before (v1):**
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def callback_handler() -> tuple[str, str | None]:
|
||||||
|
params = parse_qs(urlparse(await wait_for_redirect()).query)
|
||||||
|
return params["code"][0], params.get("state", [None])[0]
|
||||||
|
```
|
||||||
|
|
||||||
|
**After (v2):**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from mcp.client.auth import AuthorizationCodeResult
|
||||||
|
|
||||||
|
|
||||||
|
async def callback_handler() -> AuthorizationCodeResult:
|
||||||
|
params = parse_qs(urlparse(await wait_for_redirect()).query)
|
||||||
|
return AuthorizationCodeResult(
|
||||||
|
code=params["code"][0],
|
||||||
|
state=params.get("state", [None])[0],
|
||||||
|
iss=params.get("iss", [None])[0],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Forward the `iss` query parameter from the redirect so the validation can run: omitting it makes the flow fail with `OAuthFlowError` against servers that advertise `authorization_response_iss_parameter_supported`, and silently skips the check for servers that send `iss` without advertising it.
|
||||||
|
|
||||||
### `get_session_id` callback removed from `streamable_http_client`
|
### `get_session_id` callback removed from `streamable_http_client`
|
||||||
|
|
||||||
The `get_session_id` callback (third element of the returned tuple) has been removed from `streamable_http_client`. The function now returns a 2-tuple `(read_stream, write_stream)` instead of a 3-tuple.
|
The `get_session_id` callback (third element of the returned tuple) has been removed from `streamable_http_client`. The function now returns a 2-tuple `(read_stream, write_stream)` instead of a 3-tuple.
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from mcp.client._transport import ReadStream, WriteStream
|
from mcp.client._transport import ReadStream, WriteStream
|
||||||
from mcp.client.auth import OAuthClientProvider, TokenStorage
|
from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider, TokenStorage
|
||||||
from mcp.client.session import ClientSession
|
from mcp.client.session import ClientSession
|
||||||
from mcp.client.sse import sse_client
|
from mcp.client.sse import sse_client
|
||||||
from mcp.client.streamable_http import streamable_http_client
|
from mcp.client.streamable_http import streamable_http_client
|
||||||
|
|
@ -69,6 +69,7 @@ class CallbackHandler(BaseHTTPRequestHandler):
|
||||||
if "code" in query_params:
|
if "code" in query_params:
|
||||||
self.callback_data["authorization_code"] = query_params["code"][0]
|
self.callback_data["authorization_code"] = query_params["code"][0]
|
||||||
self.callback_data["state"] = query_params.get("state", [None])[0]
|
self.callback_data["state"] = query_params.get("state", [None])[0]
|
||||||
|
self.callback_data["iss"] = query_params.get("iss", [None])[0]
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
self.send_header("Content-type", "text/html")
|
self.send_header("Content-type", "text/html")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
|
@ -112,7 +113,7 @@ class CallbackServer:
|
||||||
self.port = port
|
self.port = port
|
||||||
self.server = None
|
self.server = None
|
||||||
self.thread = None
|
self.thread = None
|
||||||
self.callback_data = {"authorization_code": None, "state": None, "error": None}
|
self.callback_data = {"authorization_code": None, "state": None, "iss": None, "error": None}
|
||||||
|
|
||||||
def _create_handler_with_data(self):
|
def _create_handler_with_data(self):
|
||||||
"""Create a handler class with access to callback data."""
|
"""Create a handler class with access to callback data."""
|
||||||
|
|
@ -156,10 +157,16 @@ class CallbackServer:
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
raise Exception("Timeout waiting for OAuth callback")
|
raise Exception("Timeout waiting for OAuth callback")
|
||||||
|
|
||||||
def get_state(self):
|
@property
|
||||||
"""Get the received state parameter."""
|
def state(self):
|
||||||
|
"""The received state parameter."""
|
||||||
return self.callback_data["state"]
|
return self.callback_data["state"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def iss(self):
|
||||||
|
"""The received iss parameter."""
|
||||||
|
return self.callback_data["iss"]
|
||||||
|
|
||||||
|
|
||||||
class SimpleAuthClient:
|
class SimpleAuthClient:
|
||||||
"""Simple MCP client with auth support."""
|
"""Simple MCP client with auth support."""
|
||||||
|
|
@ -183,12 +190,12 @@ class SimpleAuthClient:
|
||||||
callback_server = CallbackServer(port=3030)
|
callback_server = CallbackServer(port=3030)
|
||||||
callback_server.start()
|
callback_server.start()
|
||||||
|
|
||||||
async def callback_handler() -> tuple[str, str | None]:
|
async def callback_handler() -> AuthorizationCodeResult:
|
||||||
"""Wait for OAuth callback and return auth code and state."""
|
"""Wait for OAuth callback and return auth code, state, and iss."""
|
||||||
print("⏳ Waiting for authorization callback...")
|
print("⏳ Waiting for authorization callback...")
|
||||||
try:
|
try:
|
||||||
auth_code = callback_server.wait_for_callback(timeout=300)
|
auth_code = callback_server.wait_for_callback(timeout=300)
|
||||||
return auth_code, callback_server.get_state()
|
return AuthorizationCodeResult(code=auth_code, state=callback_server.state, iss=callback_server.iss)
|
||||||
finally:
|
finally:
|
||||||
callback_server.stop()
|
callback_server.stop()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import httpx
|
||||||
from pydantic import AnyUrl
|
from pydantic import AnyUrl
|
||||||
|
|
||||||
from mcp import ClientSession
|
from mcp import ClientSession
|
||||||
from mcp.client.auth import OAuthClientProvider, TokenStorage
|
from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider, TokenStorage
|
||||||
from mcp.client.streamable_http import streamable_http_client
|
from mcp.client.streamable_http import streamable_http_client
|
||||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
|
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
|
||||||
|
|
||||||
|
|
@ -46,10 +46,14 @@ async def handle_redirect(auth_url: str) -> None:
|
||||||
print(f"Visit: {auth_url}")
|
print(f"Visit: {auth_url}")
|
||||||
|
|
||||||
|
|
||||||
async def handle_callback() -> tuple[str, str | None]:
|
async def handle_callback() -> AuthorizationCodeResult:
|
||||||
callback_url = input("Paste callback URL: ")
|
callback_url = input("Paste callback URL: ")
|
||||||
params = parse_qs(urlparse(callback_url).query)
|
params = parse_qs(urlparse(callback_url).query)
|
||||||
return params["code"][0], params.get("state", [None])[0]
|
return AuthorizationCodeResult(
|
||||||
|
code=params["code"][0],
|
||||||
|
state=params.get("state", [None])[0],
|
||||||
|
iss=params.get("iss", [None])[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,10 @@ from mcp.client.auth.oauth2 import (
|
||||||
PKCEParameters,
|
PKCEParameters,
|
||||||
TokenStorage,
|
TokenStorage,
|
||||||
)
|
)
|
||||||
|
from mcp.shared.auth import AuthorizationCodeResult
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"AuthorizationCodeResult",
|
||||||
"OAuthClientProvider",
|
"OAuthClientProvider",
|
||||||
"OAuthFlowError",
|
"OAuthFlowError",
|
||||||
"OAuthRegistrationError",
|
"OAuthRegistrationError",
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import jwt
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from mcp.client.auth import OAuthClientProvider, OAuthFlowError, OAuthTokenError, TokenStorage
|
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):
|
class ClientCredentialsOAuthProvider(OAuthClientProvider):
|
||||||
|
|
@ -405,7 +405,7 @@ class RFC7523OAuthClientProvider(OAuthClientProvider):
|
||||||
client_metadata: OAuthClientMetadata,
|
client_metadata: OAuthClientMetadata,
|
||||||
storage: TokenStorage,
|
storage: TokenStorage,
|
||||||
redirect_handler: Callable[[str], Awaitable[None]] | None = None,
|
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,
|
timeout: float = 300.0,
|
||||||
jwt_parameters: JWTParameters | None = None,
|
jwt_parameters: JWTParameters | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,12 @@ from mcp.client.auth.utils import (
|
||||||
handle_token_response_scopes,
|
handle_token_response_scopes,
|
||||||
is_valid_client_metadata_url,
|
is_valid_client_metadata_url,
|
||||||
should_use_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.client.streamable_http import MCP_PROTOCOL_VERSION
|
||||||
from mcp.shared.auth import (
|
from mcp.shared.auth import (
|
||||||
|
AuthorizationCodeResult,
|
||||||
OAuthClientInformationFull,
|
OAuthClientInformationFull,
|
||||||
OAuthClientMetadata,
|
OAuthClientMetadata,
|
||||||
OAuthMetadata,
|
OAuthMetadata,
|
||||||
|
|
@ -97,7 +100,7 @@ class OAuthContext:
|
||||||
client_metadata: OAuthClientMetadata
|
client_metadata: OAuthClientMetadata
|
||||||
storage: TokenStorage
|
storage: TokenStorage
|
||||||
redirect_handler: Callable[[str], Awaitable[None]] | None
|
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
|
timeout: float = 300.0
|
||||||
client_metadata_url: str | None = None
|
client_metadata_url: str | None = None
|
||||||
|
|
||||||
|
|
@ -227,7 +230,7 @@ class OAuthClientProvider(httpx.Auth):
|
||||||
client_metadata: OAuthClientMetadata,
|
client_metadata: OAuthClientMetadata,
|
||||||
storage: TokenStorage,
|
storage: TokenStorage,
|
||||||
redirect_handler: Callable[[str], Awaitable[None]] | None = None,
|
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,
|
timeout: float = 300.0,
|
||||||
client_metadata_url: str | None = None,
|
client_metadata_url: str | None = None,
|
||||||
validate_resource_url: Callable[[str, str | None], Awaitable[None]] | 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)
|
await self.context.redirect_handler(authorization_url)
|
||||||
|
|
||||||
# Wait for callback
|
# 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):
|
if result.state is None or not secrets.compare_digest(result.state, state):
|
||||||
raise OAuthFlowError(f"State parameter mismatch: {returned_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")
|
raise OAuthFlowError("No authorization code received")
|
||||||
|
|
||||||
# Return auth code and code verifier for token exchange
|
# 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:
|
def _get_token_endpoint(self) -> str:
|
||||||
if self.context.oauth_metadata and self.context.oauth_metadata.token_endpoint:
|
if self.context.oauth_metadata and self.context.oauth_metadata.token_endpoint:
|
||||||
|
|
@ -570,6 +576,9 @@ class OAuthClientProvider(httpx.Auth):
|
||||||
if not ok:
|
if not ok:
|
||||||
break
|
break
|
||||||
if ok and asm:
|
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
|
self.context.oauth_metadata = asm
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from urllib.parse import urljoin, urlparse
|
||||||
from httpx import Request, Response
|
from httpx import Request, Response
|
||||||
from pydantic import AnyUrl, ValidationError
|
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.client.streamable_http import MCP_PROTOCOL_VERSION
|
||||||
from mcp.shared.auth import (
|
from mcp.shared.auth import (
|
||||||
OAuthClientInformationFull,
|
OAuthClientInformationFull,
|
||||||
|
|
@ -211,6 +211,45 @@ async def handle_auth_metadata_response(response: Response) -> tuple[bool, OAuth
|
||||||
return True, None
|
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:
|
def create_oauth_metadata_request(url: str) -> Request:
|
||||||
return Request("GET", url, headers={MCP_PROTOCOL_VERSION: LATEST_PROTOCOL_VERSION})
|
return Request("GET", url, headers={MCP_PROTOCOL_VERSION: LATEST_PROTOCOL_VERSION})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,18 @@ class OAuthToken(BaseModel):
|
||||||
return v # pragma: no cover
|
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):
|
class InvalidScopeError(Exception):
|
||||||
def __init__(self, message: str):
|
def __init__(self, message: str):
|
||||||
self.message = message
|
self.message = message
|
||||||
|
|
@ -149,6 +161,7 @@ class OAuthMetadata(BaseModel):
|
||||||
introspection_endpoint_auth_signing_alg_values_supported: list[str] | None = None
|
introspection_endpoint_auth_signing_alg_values_supported: list[str] | None = None
|
||||||
code_challenge_methods_supported: list[str] | None = None
|
code_challenge_methods_supported: list[str] | None = None
|
||||||
client_id_metadata_document_supported: bool | None = None
|
client_id_metadata_document_supported: bool | None = None
|
||||||
|
authorization_response_iss_parameter_supported: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class ProtectedResourceMetadata(BaseModel):
|
class ProtectedResourceMetadata(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,13 @@ from mcp.client.auth.extensions.client_credentials import (
|
||||||
SignedJWTParameters,
|
SignedJWTParameters,
|
||||||
static_assertion_provider,
|
static_assertion_provider,
|
||||||
)
|
)
|
||||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthMetadata, OAuthToken
|
from mcp.shared.auth import (
|
||||||
|
AuthorizationCodeResult,
|
||||||
|
OAuthClientInformationFull,
|
||||||
|
OAuthClientMetadata,
|
||||||
|
OAuthMetadata,
|
||||||
|
OAuthToken,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MockTokenStorage:
|
class MockTokenStorage:
|
||||||
|
|
@ -57,9 +63,9 @@ def rfc7523_oauth_provider(client_metadata: OAuthClientMetadata, mock_storage: M
|
||||||
"""Mock redirect handler."""
|
"""Mock redirect handler."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def callback_handler() -> tuple[str, str | None]: # pragma: no cover
|
async def callback_handler() -> AuthorizationCodeResult: # pragma: no cover
|
||||||
"""Mock callback handler."""
|
"""Mock callback handler."""
|
||||||
return "test_auth_code", "test_state"
|
return AuthorizationCodeResult(code="test_auth_code", state="test_state")
|
||||||
|
|
||||||
with warnings.catch_warnings():
|
with warnings.catch_warnings():
|
||||||
warnings.simplefilter("ignore", DeprecationWarning)
|
warnings.simplefilter("ignore", DeprecationWarning)
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,13 @@ from mcp.client.auth.utils import (
|
||||||
handle_registration_response,
|
handle_registration_response,
|
||||||
is_valid_client_metadata_url,
|
is_valid_client_metadata_url,
|
||||||
should_use_client_metadata_url,
|
should_use_client_metadata_url,
|
||||||
|
validate_authorization_response_iss,
|
||||||
|
validate_metadata_issuer,
|
||||||
)
|
)
|
||||||
from mcp.server.auth.routes import build_metadata
|
from mcp.server.auth.routes import build_metadata
|
||||||
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
|
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
|
||||||
from mcp.shared.auth import (
|
from mcp.shared.auth import (
|
||||||
|
AuthorizationCodeResult,
|
||||||
OAuthClientInformationFull,
|
OAuthClientInformationFull,
|
||||||
OAuthClientMetadata,
|
OAuthClientMetadata,
|
||||||
OAuthMetadata,
|
OAuthMetadata,
|
||||||
|
|
@ -89,9 +92,9 @@ def oauth_provider(client_metadata: OAuthClientMetadata, mock_storage: MockToken
|
||||||
"""Mock redirect handler."""
|
"""Mock redirect handler."""
|
||||||
pass # pragma: no cover
|
pass # pragma: no cover
|
||||||
|
|
||||||
async def callback_handler() -> tuple[str, str | None]:
|
async def callback_handler() -> AuthorizationCodeResult:
|
||||||
"""Mock callback handler."""
|
"""Mock callback handler."""
|
||||||
return "test_auth_code", "test_state" # pragma: no cover
|
return AuthorizationCodeResult(code="test_auth_code", state="test_state") # pragma: no cover
|
||||||
|
|
||||||
return OAuthClientProvider(
|
return OAuthClientProvider(
|
||||||
server_url="https://api.example.com/v1/mcp",
|
server_url="https://api.example.com/v1/mcp",
|
||||||
|
|
@ -272,8 +275,8 @@ class TestOAuthFlow:
|
||||||
async def redirect_handler(url: str) -> None:
|
async def redirect_handler(url: str) -> None:
|
||||||
pass # pragma: no cover
|
pass # pragma: no cover
|
||||||
|
|
||||||
async def callback_handler() -> tuple[str, str | None]:
|
async def callback_handler() -> AuthorizationCodeResult:
|
||||||
return "test_auth_code", "test_state" # pragma: no cover
|
return AuthorizationCodeResult(code="test_auth_code", state="test_state") # pragma: no cover
|
||||||
|
|
||||||
provider = OAuthClientProvider(
|
provider = OAuthClientProvider(
|
||||||
server_url="https://api.example.com",
|
server_url="https://api.example.com",
|
||||||
|
|
@ -1380,8 +1383,8 @@ class TestAuthFlow:
|
||||||
oauth_provider.context.redirect_handler = capture_redirect
|
oauth_provider.context.redirect_handler = capture_redirect
|
||||||
|
|
||||||
# Mock callback
|
# Mock callback
|
||||||
async def mock_callback() -> tuple[str, str | None]:
|
async def mock_callback() -> AuthorizationCodeResult:
|
||||||
return "auth_code", captured_state
|
return AuthorizationCodeResult(code="auth_code", state=captured_state)
|
||||||
|
|
||||||
oauth_provider.context.callback_handler = mock_callback
|
oauth_provider.context.callback_handler = mock_callback
|
||||||
|
|
||||||
|
|
@ -1517,8 +1520,8 @@ class TestLegacyServerFallback:
|
||||||
async def redirect_handler(url: str) -> None:
|
async def redirect_handler(url: str) -> None:
|
||||||
pass # pragma: no cover
|
pass # pragma: no cover
|
||||||
|
|
||||||
async def callback_handler() -> tuple[str, str | None]:
|
async def callback_handler() -> AuthorizationCodeResult:
|
||||||
return "test_auth_code", "test_state" # pragma: no cover
|
return AuthorizationCodeResult(code="test_auth_code", state="test_state") # pragma: no cover
|
||||||
|
|
||||||
# Simulate a legacy server like Linear
|
# Simulate a legacy server like Linear
|
||||||
provider = OAuthClientProvider(
|
provider = OAuthClientProvider(
|
||||||
|
|
@ -1616,8 +1619,8 @@ class TestLegacyServerFallback:
|
||||||
async def redirect_handler(url: str) -> None:
|
async def redirect_handler(url: str) -> None:
|
||||||
pass # pragma: no cover
|
pass # pragma: no cover
|
||||||
|
|
||||||
async def callback_handler() -> tuple[str, str | None]:
|
async def callback_handler() -> AuthorizationCodeResult:
|
||||||
return "test_auth_code", "test_state" # pragma: no cover
|
return AuthorizationCodeResult(code="test_auth_code", state="test_state") # pragma: no cover
|
||||||
|
|
||||||
provider = OAuthClientProvider(
|
provider = OAuthClientProvider(
|
||||||
server_url="https://api.example.com/v1/mcp",
|
server_url="https://api.example.com/v1/mcp",
|
||||||
|
|
@ -1721,8 +1724,8 @@ class TestSEP985Discovery:
|
||||||
async def redirect_handler(url: str) -> None:
|
async def redirect_handler(url: str) -> None:
|
||||||
pass # pragma: no cover
|
pass # pragma: no cover
|
||||||
|
|
||||||
async def callback_handler() -> tuple[str, str | None]:
|
async def callback_handler() -> AuthorizationCodeResult:
|
||||||
return "test_auth_code", "test_state" # pragma: no cover
|
return AuthorizationCodeResult(code="test_auth_code", state="test_state") # pragma: no cover
|
||||||
|
|
||||||
provider = OAuthClientProvider(
|
provider = OAuthClientProvider(
|
||||||
server_url="https://api.example.com/v1/mcp",
|
server_url="https://api.example.com/v1/mcp",
|
||||||
|
|
@ -1756,8 +1759,8 @@ class TestSEP985Discovery:
|
||||||
async def redirect_handler(url: str) -> None:
|
async def redirect_handler(url: str) -> None:
|
||||||
pass # pragma: no cover
|
pass # pragma: no cover
|
||||||
|
|
||||||
async def callback_handler() -> tuple[str, str | None]:
|
async def callback_handler() -> AuthorizationCodeResult:
|
||||||
return "test_auth_code", "test_state" # pragma: no cover
|
return AuthorizationCodeResult(code="test_auth_code", state="test_state") # pragma: no cover
|
||||||
|
|
||||||
provider = OAuthClientProvider(
|
provider = OAuthClientProvider(
|
||||||
server_url="https://api.example.com/v1/mcp",
|
server_url="https://api.example.com/v1/mcp",
|
||||||
|
|
@ -1857,8 +1860,8 @@ class TestSEP985Discovery:
|
||||||
async def redirect_handler(url: str) -> None:
|
async def redirect_handler(url: str) -> None:
|
||||||
pass # pragma: no cover
|
pass # pragma: no cover
|
||||||
|
|
||||||
async def callback_handler() -> tuple[str, str | None]:
|
async def callback_handler() -> AuthorizationCodeResult:
|
||||||
return "test_auth_code", "test_state" # pragma: no cover
|
return AuthorizationCodeResult(code="test_auth_code", state="test_state") # pragma: no cover
|
||||||
|
|
||||||
provider = OAuthClientProvider(
|
provider = OAuthClientProvider(
|
||||||
server_url="https://api.example.com/v1/mcp",
|
server_url="https://api.example.com/v1/mcp",
|
||||||
|
|
@ -2071,8 +2074,8 @@ class TestCIMD:
|
||||||
async def redirect_handler(url: str) -> None:
|
async def redirect_handler(url: str) -> None:
|
||||||
pass # pragma: no cover
|
pass # pragma: no cover
|
||||||
|
|
||||||
async def callback_handler() -> tuple[str, str | None]:
|
async def callback_handler() -> AuthorizationCodeResult:
|
||||||
return "test_auth_code", "test_state" # pragma: no cover
|
return AuthorizationCodeResult(code="test_auth_code", state="test_state") # pragma: no cover
|
||||||
|
|
||||||
provider = OAuthClientProvider(
|
provider = OAuthClientProvider(
|
||||||
server_url="https://api.example.com/v1/mcp",
|
server_url="https://api.example.com/v1/mcp",
|
||||||
|
|
@ -2092,8 +2095,8 @@ class TestCIMD:
|
||||||
async def redirect_handler(url: str) -> None:
|
async def redirect_handler(url: str) -> None:
|
||||||
pass # pragma: no cover
|
pass # pragma: no cover
|
||||||
|
|
||||||
async def callback_handler() -> tuple[str, str | None]:
|
async def callback_handler() -> AuthorizationCodeResult:
|
||||||
return "test_auth_code", "test_state" # pragma: no cover
|
return AuthorizationCodeResult(code="test_auth_code", state="test_state") # pragma: no cover
|
||||||
|
|
||||||
with pytest.raises(ValueError) as exc_info:
|
with pytest.raises(ValueError) as exc_info:
|
||||||
OAuthClientProvider(
|
OAuthClientProvider(
|
||||||
|
|
@ -2115,8 +2118,8 @@ class TestCIMD:
|
||||||
async def redirect_handler(url: str) -> None:
|
async def redirect_handler(url: str) -> None:
|
||||||
pass # pragma: no cover
|
pass # pragma: no cover
|
||||||
|
|
||||||
async def callback_handler() -> tuple[str, str | None]:
|
async def callback_handler() -> AuthorizationCodeResult:
|
||||||
return "test_auth_code", "test_state" # pragma: no cover
|
return AuthorizationCodeResult(code="test_auth_code", state="test_state") # pragma: no cover
|
||||||
|
|
||||||
provider = OAuthClientProvider(
|
provider = OAuthClientProvider(
|
||||||
server_url="https://api.example.com/v1/mcp",
|
server_url="https://api.example.com/v1/mcp",
|
||||||
|
|
@ -2206,8 +2209,8 @@ class TestCIMD:
|
||||||
async def redirect_handler(url: str) -> None:
|
async def redirect_handler(url: str) -> None:
|
||||||
pass # pragma: no cover
|
pass # pragma: no cover
|
||||||
|
|
||||||
async def callback_handler() -> tuple[str, str | None]:
|
async def callback_handler() -> AuthorizationCodeResult:
|
||||||
return "test_auth_code", "test_state" # pragma: no cover
|
return AuthorizationCodeResult(code="test_auth_code", state="test_state") # pragma: no cover
|
||||||
|
|
||||||
provider = OAuthClientProvider(
|
provider = OAuthClientProvider(
|
||||||
server_url="https://api.example.com/v1/mcp",
|
server_url="https://api.example.com/v1/mcp",
|
||||||
|
|
@ -2439,8 +2442,8 @@ class TestSEP2207OfflineAccessScope:
|
||||||
params = parse_qs(parsed.query)
|
params = parse_qs(parsed.query)
|
||||||
captured_state = params.get("state", [None])[0]
|
captured_state = params.get("state", [None])[0]
|
||||||
|
|
||||||
async def callback_handler() -> tuple[str, str | None]:
|
async def callback_handler() -> AuthorizationCodeResult:
|
||||||
return "test_auth_code", captured_state
|
return AuthorizationCodeResult(code="test_auth_code", state=captured_state)
|
||||||
|
|
||||||
provider = OAuthClientProvider(
|
provider = OAuthClientProvider(
|
||||||
server_url="https://api.example.com/v1/mcp",
|
server_url="https://api.example.com/v1/mcp",
|
||||||
|
|
@ -2548,8 +2551,8 @@ class TestSEP2207OfflineAccessScope:
|
||||||
params = parse_qs(parsed.query)
|
params = parse_qs(parsed.query)
|
||||||
captured_state = params.get("state", [None])[0]
|
captured_state = params.get("state", [None])[0]
|
||||||
|
|
||||||
async def callback_handler() -> tuple[str, str | None]:
|
async def callback_handler() -> AuthorizationCodeResult:
|
||||||
return "test_auth_code", captured_state
|
return AuthorizationCodeResult(code="test_auth_code", state=captured_state)
|
||||||
|
|
||||||
provider = OAuthClientProvider(
|
provider = OAuthClientProvider(
|
||||||
server_url="https://api.example.com/v1/mcp",
|
server_url="https://api.example.com/v1/mcp",
|
||||||
|
|
@ -2636,3 +2639,65 @@ class TestSEP2207OfflineAccessScope:
|
||||||
await auth_flow.asend(final_response)
|
await auth_flow.asend(final_response)
|
||||||
except StopAsyncIteration:
|
except StopAsyncIteration:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_ISSUER = "https://as.example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def _issuer_metadata(*, issuer: str = _ISSUER, iss_supported: bool | None = None) -> OAuthMetadata:
|
||||||
|
# Validate from string inputs so url_preserve_empty_path keeps the issuer as transmitted,
|
||||||
|
# matching the wire path (model_validate_json) rather than normalizing a bare authority.
|
||||||
|
return OAuthMetadata.model_validate(
|
||||||
|
{
|
||||||
|
"issuer": issuer,
|
||||||
|
"authorization_endpoint": f"{issuer}/authorize",
|
||||||
|
"token_endpoint": f"{issuer}/token",
|
||||||
|
"authorization_response_iss_parameter_supported": iss_supported,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("issuer", "iss", "iss_supported"),
|
||||||
|
[
|
||||||
|
pytest.param(_ISSUER, _ISSUER, True, id="advertised-and-correct"),
|
||||||
|
pytest.param(_ISSUER, None, None, id="not-advertised-and-omitted"),
|
||||||
|
pytest.param(_ISSUER, _ISSUER, None, id="not-advertised-but-correct"),
|
||||||
|
# An issuer that genuinely ends in a slash (e.g. Auth0) must match its own iss.
|
||||||
|
pytest.param("https://as.example.com/", "https://as.example.com/", True, id="trailing-slash-issuer"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_validate_authorization_response_iss_accepts(issuer: str, iss: str | None, iss_supported: bool | None):
|
||||||
|
"""RFC 9207: a matching or legitimately absent iss is accepted."""
|
||||||
|
validate_authorization_response_iss(iss, _issuer_metadata(issuer=issuer, iss_supported=iss_supported))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("iss", "iss_supported", "match"),
|
||||||
|
[
|
||||||
|
pytest.param(None, True, "missing iss", id="advertised-but-omitted"),
|
||||||
|
pytest.param("https://evil.example.com", True, "iss mismatch", id="wrong-issuer"),
|
||||||
|
pytest.param("https://evil.example.com", None, "iss mismatch", id="unexpected-when-not-advertised"),
|
||||||
|
pytest.param(f"{_ISSUER}/", True, "iss mismatch", id="trailing-slash-not-normalized"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_validate_authorization_response_iss_rejects(iss: str | None, iss_supported: bool | None, match: str):
|
||||||
|
"""RFC 9207: a mismatched iss, or one missing when advertised, is rejected via simple string compare."""
|
||||||
|
with pytest.raises(OAuthFlowError, match=match):
|
||||||
|
validate_authorization_response_iss(iss, _issuer_metadata(iss_supported=iss_supported))
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_authorization_response_iss_without_metadata():
|
||||||
|
"""With no AS metadata, a present iss is rejected and an absent one is accepted."""
|
||||||
|
validate_authorization_response_iss(None, None)
|
||||||
|
with pytest.raises(OAuthFlowError, match="iss mismatch"):
|
||||||
|
validate_authorization_response_iss(_ISSUER, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_metadata_issuer_accepts_match():
|
||||||
|
validate_metadata_issuer(_issuer_metadata(issuer=_ISSUER), _ISSUER)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_metadata_issuer_rejects_mismatch():
|
||||||
|
with pytest.raises(OAuthFlowError, match="metadata issuer mismatch"):
|
||||||
|
validate_metadata_issuer(_issuer_metadata(issuer="https://attacker.example.com"), _ISSUER)
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,12 @@ import pytest
|
||||||
from pydantic import AnyUrl
|
from pydantic import AnyUrl
|
||||||
|
|
||||||
from mcp.client.auth import OAuthClientProvider
|
from mcp.client.auth import OAuthClientProvider
|
||||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
|
from mcp.shared.auth import (
|
||||||
|
AuthorizationCodeResult,
|
||||||
|
OAuthClientInformationFull,
|
||||||
|
OAuthClientMetadata,
|
||||||
|
OAuthToken,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MockTokenStorage:
|
class MockTokenStorage:
|
||||||
|
|
@ -48,8 +53,8 @@ async def test_401_uses_www_auth_scope_not_resource_metadata_url():
|
||||||
async def redirect_handler(url: str) -> None:
|
async def redirect_handler(url: str) -> None:
|
||||||
pass # pragma: no cover
|
pass # pragma: no cover
|
||||||
|
|
||||||
async def callback_handler() -> tuple[str, str | None]:
|
async def callback_handler() -> AuthorizationCodeResult:
|
||||||
return "test_auth_code", "test_state" # pragma: no cover
|
return AuthorizationCodeResult(code="test_auth_code", state="test_state") # pragma: no cover
|
||||||
|
|
||||||
client_metadata = OAuthClientMetadata(
|
client_metadata = OAuthClientMetadata(
|
||||||
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
|
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
|
||||||
|
|
|
||||||
|
|
@ -3279,16 +3279,10 @@ REQUIREMENTS: dict[str, Requirement] = {
|
||||||
source=f"{SPEC_BASE_URL}/basic/authorization#authorization-server-metadata-discovery",
|
source=f"{SPEC_BASE_URL}/basic/authorization#authorization-server-metadata-discovery",
|
||||||
behavior=(
|
behavior=(
|
||||||
"The client rejects authorization-server metadata whose issuer does not match the URL the "
|
"The client rejects authorization-server metadata whose issuer does not match the URL the "
|
||||||
"metadata was retrieved from (RFC 8414 section 3.3)."
|
"metadata was retrieved from (RFC 8414 section 3.3 / SEP-2468)."
|
||||||
),
|
),
|
||||||
transports=("streamable-http",),
|
transports=("streamable-http",),
|
||||||
note="OAuth is HTTP-only.",
|
note="OAuth is HTTP-only.",
|
||||||
divergence=Divergence(
|
|
||||||
note=(
|
|
||||||
"The SDK parses authorization-server metadata without comparing issuer to the discovery "
|
|
||||||
"URL; a mismatched issuer is accepted and the flow proceeds."
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
"client-auth:authorize:error-surfaces": Requirement(
|
"client-auth:authorize:error-surfaces": Requirement(
|
||||||
source=f"{SPEC_BASE_URL}/basic/authorization#authorization-flow-steps",
|
source=f"{SPEC_BASE_URL}/basic/authorization#authorization-flow-steps",
|
||||||
|
|
@ -3492,6 +3486,16 @@ REQUIREMENTS: dict[str, Requirement] = {
|
||||||
transports=("streamable-http",),
|
transports=("streamable-http",),
|
||||||
note="OAuth is HTTP-only.",
|
note="OAuth is HTTP-only.",
|
||||||
),
|
),
|
||||||
|
"client-auth:authorization-response:iss-verify": Requirement(
|
||||||
|
source=f"{SPEC_BASE_URL}/basic/authorization#authorization-server-metadata-discovery",
|
||||||
|
behavior=(
|
||||||
|
"The client validates the RFC 9207 iss authorization-response parameter against the "
|
||||||
|
"authorization server issuer (simple string comparison) and rejects a mismatch, or a "
|
||||||
|
"missing iss when the server advertises support (SEP-2468)."
|
||||||
|
),
|
||||||
|
transports=("streamable-http",),
|
||||||
|
note="OAuth is HTTP-only.",
|
||||||
|
),
|
||||||
"client-auth:token-endpoint-auth-method": Requirement(
|
"client-auth:token-endpoint-auth-method": Requirement(
|
||||||
source="sdk",
|
source="sdk",
|
||||||
behavior="The client authenticates to the token endpoint using the auth method established at registration.",
|
behavior="The client authenticates to the token endpoint using the auth method established at registration.",
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ from mcp.client.streamable_http import streamable_http_client
|
||||||
from mcp.server import Server
|
from mcp.server import Server
|
||||||
from mcp.server.auth.provider import AccessToken, ProviderTokenVerifier
|
from mcp.server.auth.provider import AccessToken, ProviderTokenVerifier
|
||||||
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions
|
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions
|
||||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
|
from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
|
||||||
from tests.interaction._connect import BASE_URL, NO_DNS_REBINDING_PROTECTION
|
from tests.interaction._connect import BASE_URL, NO_DNS_REBINDING_PROTECTION
|
||||||
from tests.interaction.auth._provider import InMemoryAuthorizationServerProvider
|
from tests.interaction.auth._provider import InMemoryAuthorizationServerProvider
|
||||||
from tests.interaction.transports._bridge import StreamingASGITransport
|
from tests.interaction.transports._bridge import StreamingASGITransport
|
||||||
|
|
@ -136,16 +136,21 @@ class HeadlessOAuth:
|
||||||
|
|
||||||
`state_override`: when set, `callback_handler` returns this value as the state instead of
|
`state_override`: when set, `callback_handler` returns this value as the state instead of
|
||||||
the one parsed from the redirect, so tests can drive the state-mismatch path.
|
the one parsed from the redirect, so tests can drive the state-mismatch path.
|
||||||
|
|
||||||
|
`iss_override`: when set, `callback_handler` returns this value as the RFC 9207 issuer
|
||||||
|
instead of the one parsed from the redirect, so tests can drive the iss-mismatch path.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, *, state_override: str | None = None) -> None:
|
def __init__(self, *, state_override: str | None = None, iss_override: str | None = None) -> None:
|
||||||
self.authorize_url: str | None = None
|
self.authorize_url: str | None = None
|
||||||
self.authorize_urls: list[str] = []
|
self.authorize_urls: list[str] = []
|
||||||
self.error: str | None = None
|
self.error: str | None = None
|
||||||
self._state_override = state_override
|
self._state_override = state_override
|
||||||
|
self._iss_override = iss_override
|
||||||
self._http: httpx.AsyncClient | None = None
|
self._http: httpx.AsyncClient | None = None
|
||||||
self._code: str = ""
|
self._code: str = ""
|
||||||
self._state: str | None = None
|
self._state: str | None = None
|
||||||
|
self._iss: str | None = None
|
||||||
|
|
||||||
def bind(self, http_client: httpx.AsyncClient) -> None:
|
def bind(self, http_client: httpx.AsyncClient) -> None:
|
||||||
self._http = http_client
|
self._http = http_client
|
||||||
|
|
@ -161,10 +166,15 @@ class HeadlessOAuth:
|
||||||
params = parse_qs(urlsplit(response.headers["location"]).query)
|
params = parse_qs(urlsplit(response.headers["location"]).query)
|
||||||
self._code = params.get("code", [""])[0]
|
self._code = params.get("code", [""])[0]
|
||||||
self._state = params.get("state", [None])[0]
|
self._state = params.get("state", [None])[0]
|
||||||
|
self._iss = params.get("iss", [None])[0]
|
||||||
self.error = params.get("error", [None])[0]
|
self.error = params.get("error", [None])[0]
|
||||||
|
|
||||||
async def callback_handler(self) -> tuple[str, str | None]:
|
async def callback_handler(self) -> AuthorizationCodeResult:
|
||||||
return self._code, self._state_override if self._state_override is not None else self._state
|
return AuthorizationCodeResult(
|
||||||
|
code=self._code,
|
||||||
|
state=self._state_override if self._state_override is not None else self._state,
|
||||||
|
iss=self._iss_override if self._iss_override is not None else self._iss,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def auth_settings(
|
def auth_settings(
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ from mcp.server.auth.provider import (
|
||||||
construct_redirect_uri,
|
construct_redirect_uri,
|
||||||
)
|
)
|
||||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
||||||
|
from tests.interaction._connect import BASE_URL
|
||||||
|
|
||||||
_TOKEN_LIFETIME_SECONDS = 3600
|
_TOKEN_LIFETIME_SECONDS = 3600
|
||||||
|
|
||||||
|
|
@ -53,9 +54,14 @@ class InMemoryAuthorizationServerProvider(
|
||||||
issue_expired_first: bool = False,
|
issue_expired_first: bool = False,
|
||||||
fail_next_refresh: bool = False,
|
fail_next_refresh: bool = False,
|
||||||
reject_all_tokens: bool = False,
|
reject_all_tokens: bool = False,
|
||||||
|
issuer: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._default_scopes = list(default_scopes) if default_scopes is not None else ["mcp"]
|
self._default_scopes = list(default_scopes) if default_scopes is not None else ["mcp"]
|
||||||
self._issuer = "http://127.0.0.1:8000"
|
# The authorization-response iss must equal the AS metadata issuer the client recorded
|
||||||
|
# (RFC 9207 simple string comparison). `real_asm` builds the issuer from an AnyHttpUrl
|
||||||
|
# object, so it carries the trailing slash; the redirect iss matches it. Path-issuer
|
||||||
|
# tests pass the recorded issuer explicitly.
|
||||||
|
self._issuer = issuer if issuer is not None else f"{BASE_URL}/"
|
||||||
self._deny_authorize = deny_authorize
|
self._deny_authorize = deny_authorize
|
||||||
self._issue_expired_first = issue_expired_first
|
self._issue_expired_first = issue_expired_first
|
||||||
self._fail_next_refresh = fail_next_refresh
|
self._fail_next_refresh = fail_next_refresh
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,24 @@ async def test_a_mismatched_state_on_the_callback_aborts_the_flow() -> None:
|
||||||
await connect_with_oauth(server, provider=provider, headless=headless).__aenter__()
|
await connect_with_oauth(server, provider=provider, headless=headless).__aenter__()
|
||||||
|
|
||||||
|
|
||||||
|
@requirement("client-auth:authorization-response:iss-verify")
|
||||||
|
async def test_a_mismatched_iss_on_the_callback_aborts_the_flow() -> None:
|
||||||
|
"""A callback whose RFC 9207 iss does not match the authorization server issuer aborts the flow.
|
||||||
|
|
||||||
|
`iss_override` makes the headless callback return an issuer the AS never advertised; the SDK
|
||||||
|
compares it to `oauth_metadata.issuer` and raises `OAuthFlowError` before the token exchange.
|
||||||
|
"""
|
||||||
|
provider = InMemoryAuthorizationServerProvider()
|
||||||
|
server = Server("guarded", on_list_tools=list_tools)
|
||||||
|
headless = HeadlessOAuth(iss_override="https://attacker.example.com")
|
||||||
|
|
||||||
|
with anyio.fail_after(5):
|
||||||
|
with pytest.RaisesGroup(
|
||||||
|
pytest.RaisesExc(OAuthFlowError, match="^Authorization response iss mismatch:"), flatten_subgroups=True
|
||||||
|
):
|
||||||
|
await connect_with_oauth(server, provider=provider, headless=headless).__aenter__()
|
||||||
|
|
||||||
|
|
||||||
@requirement("client-auth:resource-parameter")
|
@requirement("client-auth:resource-parameter")
|
||||||
async def test_the_authorization_code_token_request_carries_grant_type_code_redirect_and_resource(
|
async def test_the_authorization_code_token_request_carries_grant_type_code_redirect_and_resource(
|
||||||
recorded_oauth_flow: RecordedFlow,
|
recorded_oauth_flow: RecordedFlow,
|
||||||
|
|
|
||||||
|
|
@ -236,7 +236,10 @@ async def test_as_metadata_discovery_falls_back_through_the_spec_endpoint_order(
|
||||||
unrecognized field to prove the client's parser ignores unknown members (RFC 8414 §3.2).
|
unrecognized field to prove the client's parser ignores unknown members (RFC 8414 §3.2).
|
||||||
"""
|
"""
|
||||||
recorded, on_request = record_requests()
|
recorded, on_request = record_requests()
|
||||||
provider = InMemoryAuthorizationServerProvider()
|
asm = real_asm()
|
||||||
|
asm.issuer = AnyHttpUrl(authorization_server)
|
||||||
|
# The redirect iss must equal the issuer the client records from this metadata.
|
||||||
|
provider = InMemoryAuthorizationServerProvider(issuer=str(asm.issuer))
|
||||||
server = Server("guarded", on_list_tools=list_tools)
|
server = Server("guarded", on_list_tools=list_tools)
|
||||||
|
|
||||||
prm = ProtectedResourceMetadata(
|
prm = ProtectedResourceMetadata(
|
||||||
|
|
@ -246,7 +249,7 @@ async def test_as_metadata_discovery_falls_back_through_the_spec_endpoint_order(
|
||||||
not_found=not_found,
|
not_found=not_found,
|
||||||
serve={
|
serve={
|
||||||
PRM_PATH_SUFFIXED: metadata_body(prm),
|
PRM_PATH_SUFFIXED: metadata_body(prm),
|
||||||
serve_at: metadata_body(real_asm(), x_unknown_extension="ignored"),
|
serve_at: metadata_body(asm, x_unknown_extension="ignored"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -311,23 +314,26 @@ async def test_as_metadata_advertises_authorize_token_registration_and_s256() ->
|
||||||
|
|
||||||
|
|
||||||
@requirement("client-auth:as-metadata-discovery:issuer-validation")
|
@requirement("client-auth:as-metadata-discovery:issuer-validation")
|
||||||
async def test_as_metadata_with_a_mismatched_issuer_is_accepted_and_the_flow_proceeds() -> None:
|
async def test_as_metadata_with_a_mismatched_issuer_aborts_the_flow() -> None:
|
||||||
"""Authorization-server metadata whose `issuer` does not match the discovery URL is accepted.
|
"""Authorization-server metadata whose `issuer` does not match the discovery URL is rejected.
|
||||||
|
|
||||||
RFC 8414 §3.3 requires the client to reject the document; the SDK parses and uses it
|
RFC 8414 §3.3 / SEP-2468 require the client to reject the document; the SDK compares `issuer`
|
||||||
without comparing `issuer` to the URL it was fetched from. See the divergence on the
|
to the URL the metadata was fetched from and raises `OAuthFlowError` before any authorize or
|
||||||
requirement. The served body carries an unrecognized field as a fold-in proof of
|
token request is made.
|
||||||
unknown-field tolerance.
|
|
||||||
"""
|
"""
|
||||||
|
recorded, on_request = record_requests()
|
||||||
provider = InMemoryAuthorizationServerProvider()
|
provider = InMemoryAuthorizationServerProvider()
|
||||||
server = Server("guarded", on_list_tools=list_tools)
|
server = Server("guarded", on_list_tools=list_tools)
|
||||||
|
|
||||||
metadata = real_asm()
|
metadata = real_asm()
|
||||||
metadata.issuer = AnyHttpUrl(f"{BASE_URL}/wrong-issuer")
|
metadata.issuer = AnyHttpUrl(f"{BASE_URL}/wrong-issuer")
|
||||||
app_shim = shim(serve={ASM_ROOT: metadata_body(metadata, x_unknown_extension="ignored")})
|
app_shim = shim(serve={ASM_ROOT: metadata_body(metadata)})
|
||||||
|
|
||||||
with anyio.fail_after(5):
|
with anyio.fail_after(5):
|
||||||
async with connect_with_oauth(server, provider=provider, app_shim=app_shim) as (client, _):
|
with pytest.RaisesGroup(
|
||||||
result = await client.list_tools()
|
pytest.RaisesExc(OAuthFlowError, match="^Authorization server metadata issuer mismatch"),
|
||||||
|
flatten_subgroups=True,
|
||||||
|
):
|
||||||
|
await connect_with_oauth(server, provider=provider, app_shim=app_shim, on_request=on_request).__aenter__()
|
||||||
|
|
||||||
assert result.tools[0].name == "probe"
|
assert [r.path for r in recorded if r.path in ("/authorize", "/token")] == []
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue