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
20 changed files with 335 additions and 103 deletions

View file

@ -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 ---

View file

@ -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

View file

@ -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

View file

@ -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():

View file

@ -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.

View file

@ -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()

View file

@ -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():

View file

@ -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",

View file

@ -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:

View file

@ -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:

View file

@ -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})

View file

@ -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):

View file

@ -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)

View file

@ -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)

View file

@ -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")],

View file

@ -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.",

View file

@ -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(

View file

@ -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

View file

@ -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,

View file

@ -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")] == []