Skip to content

Commit 48cf495

Browse files
authored
Validate the iss authorization-response parameter (RFC 9207 / SEP-2468) (#2921)
1 parent b7a5bff commit 48cf495

20 files changed

Lines changed: 335 additions & 103 deletions

File tree

.github/actions/conformance/client.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
)
4444
from mcp.client.context import ClientRequestContext
4545
from mcp.client.streamable_http import streamable_http_client
46-
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
46+
from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
4747

4848
# Set up logging to stderr (stdout is for conformance test output)
4949
logging.basicConfig(
@@ -119,6 +119,7 @@ class ConformanceOAuthCallbackHandler:
119119
def __init__(self) -> None:
120120
self._auth_code: str | None = None
121121
self._state: str | None = None
122+
self._iss: str | None = None
122123

123124
async def handle_redirect(self, authorization_url: str) -> None:
124125
"""Fetch the authorization URL and extract the auth code from the redirect."""
@@ -140,6 +141,8 @@ async def handle_redirect(self, authorization_url: str) -> None:
140141
self._auth_code = query_params["code"][0]
141142
state_values = query_params.get("state")
142143
self._state = state_values[0] if state_values else None
144+
iss_values = query_params.get("iss")
145+
self._iss = iss_values[0] if iss_values else None
143146
logger.debug(f"Got auth code from redirect: {self._auth_code[:10]}...")
144147
return
145148
else:
@@ -149,15 +152,15 @@ async def handle_redirect(self, authorization_url: str) -> None:
149152
else:
150153
raise RuntimeError(f"Expected redirect response, got {response.status_code} from {authorization_url}")
151154

152-
async def handle_callback(self) -> tuple[str, str | None]:
153-
"""Return the captured auth code and state."""
155+
async def handle_callback(self) -> AuthorizationCodeResult:
156+
"""Return the captured auth code, state, and iss."""
154157
if self._auth_code is None:
155158
raise RuntimeError("No authorization code available - was handle_redirect called?")
156-
auth_code = self._auth_code
157-
state = self._state
159+
result = AuthorizationCodeResult(code=self._auth_code, state=self._state, iss=self._iss)
158160
self._auth_code = None
159161
self._state = None
160-
return auth_code, state
162+
self._iss = None
163+
return result
161164

162165

163166
# --- Scenario Handlers ---

.github/actions/conformance/expected-failures.2026-07-28.yml

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ client:
4444
- auth/token-endpoint-auth-post
4545
- auth/token-endpoint-auth-none
4646
- auth/offline-access-not-supported
47+
# SEP-2468 (authorization response iss parameter) is implemented, but these
48+
# 2026-introduced scenarios reach DCR and so still fail the application_type
49+
# check above; they unblock with SEP-837, not SEP-2468.
50+
- auth/iss-supported
51+
- auth/iss-not-advertised
52+
- auth/iss-supported-missing
53+
- auth/iss-wrong-issuer
54+
- auth/iss-unexpected
55+
- auth/iss-normalized
4756

4857
# --- Auth scenarios cut short by the 2026 connection lifecycle ---
4958
# The auth fixture flow drives the 2025 stateful lifecycle; the 2026-mode
@@ -65,14 +74,6 @@ client:
6574
- http-invalid-tool-headers
6675
# SEP-2106 (JSON Schema $ref handling): client still dereferences network $refs.
6776
- json-schema-ref-no-deref
68-
# SEP-2468 (authorization response iss parameter): not implemented in the client.
69-
- auth/iss-supported
70-
- auth/iss-not-advertised
71-
- auth/iss-supported-missing
72-
- auth/iss-wrong-issuer
73-
- auth/iss-unexpected
74-
- auth/iss-normalized
75-
- auth/metadata-issuer-mismatch
7677
# SEP-2352 (authorization server migration): client does not re-register when
7778
# PRM authorization_servers changes.
7879
- auth/authorization-server-migration

.github/actions/conformance/expected-failures.yml

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,21 @@ client:
2424
- http-invalid-tool-headers
2525
# SEP-2106 (JSON Schema $ref handling): client still dereferences network $refs.
2626
- json-schema-ref-no-deref
27-
# SEP-2468 (authorization response iss parameter): not implemented in the client.
27+
# SEP-2352 (authorization server migration): client does not re-register when
28+
# PRM authorization_servers changes.
29+
- auth/authorization-server-migration
30+
# SEP-837 (application_type during DCR): the check fires on every non-legacy
31+
# spec version (the default LATEST is 2026-07-28). The client omits
32+
# application_type during Dynamic Client Registration, so every scenario that
33+
# reaches DCR fails it. SEP-2468 iss validation is implemented, so these now
34+
# fail only on the application_type check, not on iss.
35+
- auth/offline-access-not-supported
2836
- auth/iss-supported
2937
- auth/iss-not-advertised
3038
- auth/iss-supported-missing
3139
- auth/iss-wrong-issuer
3240
- auth/iss-unexpected
3341
- auth/iss-normalized
34-
- auth/metadata-issuer-mismatch
35-
# SEP-2352 (authorization server migration): client does not re-register when
36-
# PRM authorization_servers changes.
37-
- auth/authorization-server-migration
38-
# SEP-837 (application_type during DCR): the check only fires on draft-version
39-
# runs; this draft scenario is the one place the client still hits it.
40-
- auth/offline-access-not-supported
4142

4243
# --- Pre-existing scenarios that fail on checks added after conformance 0.1.15 ---
4344
# SEP-2350 (scope step-up): WARNING-only; the expected-failures evaluator

README.v2.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2323,7 +2323,7 @@ import httpx
23232323
from pydantic import AnyUrl
23242324

23252325
from mcp import ClientSession
2326-
from mcp.client.auth import OAuthClientProvider, TokenStorage
2326+
from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider, TokenStorage
23272327
from mcp.client.streamable_http import streamable_http_client
23282328
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
23292329

@@ -2356,10 +2356,14 @@ async def handle_redirect(auth_url: str) -> None:
23562356
print(f"Visit: {auth_url}")
23572357

23582358

2359-
async def handle_callback() -> tuple[str, str | None]:
2359+
async def handle_callback() -> AuthorizationCodeResult:
23602360
callback_url = input("Paste callback URL: ")
23612361
params = parse_qs(urlparse(callback_url).query)
2362-
return params["code"][0], params.get("state", [None])[0]
2362+
return AuthorizationCodeResult(
2363+
code=params["code"][0],
2364+
state=params.get("state", [None])[0],
2365+
iss=params.get("iss", [None])[0],
2366+
)
23632367

23642368

23652369
async def main():

docs/migration.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,35 @@ async with http_client:
6262

6363
v1's internal client set `follow_redirects=True`; set it explicitly when supplying your own `httpx.AsyncClient` to preserve that behavior.
6464

65+
### OAuth `callback_handler` returns `AuthorizationCodeResult`
66+
67+
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`.
68+
69+
**Before (v1):**
70+
71+
```python
72+
async def callback_handler() -> tuple[str, str | None]:
73+
params = parse_qs(urlparse(await wait_for_redirect()).query)
74+
return params["code"][0], params.get("state", [None])[0]
75+
```
76+
77+
**After (v2):**
78+
79+
```python
80+
from mcp.client.auth import AuthorizationCodeResult
81+
82+
83+
async def callback_handler() -> AuthorizationCodeResult:
84+
params = parse_qs(urlparse(await wait_for_redirect()).query)
85+
return AuthorizationCodeResult(
86+
code=params["code"][0],
87+
state=params.get("state", [None])[0],
88+
iss=params.get("iss", [None])[0],
89+
)
90+
```
91+
92+
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.
93+
6594
### `get_session_id` callback removed from `streamable_http_client`
6695

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

examples/clients/simple-auth-client/mcp_simple_auth_client/main.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
import httpx
2121
from mcp.client._transport import ReadStream, WriteStream
22-
from mcp.client.auth import OAuthClientProvider, TokenStorage
22+
from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider, TokenStorage
2323
from mcp.client.session import ClientSession
2424
from mcp.client.sse import sse_client
2525
from mcp.client.streamable_http import streamable_http_client
@@ -69,6 +69,7 @@ def do_GET(self):
6969
if "code" in query_params:
7070
self.callback_data["authorization_code"] = query_params["code"][0]
7171
self.callback_data["state"] = query_params.get("state", [None])[0]
72+
self.callback_data["iss"] = query_params.get("iss", [None])[0]
7273
self.send_response(200)
7374
self.send_header("Content-type", "text/html")
7475
self.end_headers()
@@ -112,7 +113,7 @@ def __init__(self, port: int = 3000):
112113
self.port = port
113114
self.server = None
114115
self.thread = None
115-
self.callback_data = {"authorization_code": None, "state": None, "error": None}
116+
self.callback_data = {"authorization_code": None, "state": None, "iss": None, "error": None}
116117

117118
def _create_handler_with_data(self):
118119
"""Create a handler class with access to callback data."""
@@ -156,10 +157,16 @@ def wait_for_callback(self, timeout: int = 300):
156157
time.sleep(0.1)
157158
raise Exception("Timeout waiting for OAuth callback")
158159

159-
def get_state(self):
160-
"""Get the received state parameter."""
160+
@property
161+
def state(self):
162+
"""The received state parameter."""
161163
return self.callback_data["state"]
162164

165+
@property
166+
def iss(self):
167+
"""The received iss parameter."""
168+
return self.callback_data["iss"]
169+
163170

164171
class SimpleAuthClient:
165172
"""Simple MCP client with auth support."""
@@ -183,12 +190,12 @@ async def connect(self):
183190
callback_server = CallbackServer(port=3030)
184191
callback_server.start()
185192

186-
async def callback_handler() -> tuple[str, str | None]:
187-
"""Wait for OAuth callback and return auth code and state."""
193+
async def callback_handler() -> AuthorizationCodeResult:
194+
"""Wait for OAuth callback and return auth code, state, and iss."""
188195
print("⏳ Waiting for authorization callback...")
189196
try:
190197
auth_code = callback_server.wait_for_callback(timeout=300)
191-
return auth_code, callback_server.get_state()
198+
return AuthorizationCodeResult(code=auth_code, state=callback_server.state, iss=callback_server.iss)
192199
finally:
193200
callback_server.stop()
194201

examples/snippets/clients/oauth_client.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from pydantic import AnyUrl
1414

1515
from mcp import ClientSession
16-
from mcp.client.auth import OAuthClientProvider, TokenStorage
16+
from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider, TokenStorage
1717
from mcp.client.streamable_http import streamable_http_client
1818
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
1919

@@ -46,10 +46,14 @@ async def handle_redirect(auth_url: str) -> None:
4646
print(f"Visit: {auth_url}")
4747

4848

49-
async def handle_callback() -> tuple[str, str | None]:
49+
async def handle_callback() -> AuthorizationCodeResult:
5050
callback_url = input("Paste callback URL: ")
5151
params = parse_qs(urlparse(callback_url).query)
52-
return params["code"][0], params.get("state", [None])[0]
52+
return AuthorizationCodeResult(
53+
code=params["code"][0],
54+
state=params.get("state", [None])[0],
55+
iss=params.get("iss", [None])[0],
56+
)
5357

5458

5559
async def main():

src/mcp/client/auth/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
PKCEParameters,
1010
TokenStorage,
1111
)
12+
from mcp.shared.auth import AuthorizationCodeResult
1213

1314
__all__ = [
15+
"AuthorizationCodeResult",
1416
"OAuthClientProvider",
1517
"OAuthFlowError",
1618
"OAuthRegistrationError",

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from pydantic import BaseModel, Field
1919

2020
from mcp.client.auth import OAuthClientProvider, OAuthFlowError, OAuthTokenError, TokenStorage
21-
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
21+
from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthClientMetadata
2222

2323

2424
class ClientCredentialsOAuthProvider(OAuthClientProvider):
@@ -405,7 +405,7 @@ def __init__(
405405
client_metadata: OAuthClientMetadata,
406406
storage: TokenStorage,
407407
redirect_handler: Callable[[str], Awaitable[None]] | None = None,
408-
callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None = None,
408+
callback_handler: Callable[[], Awaitable[AuthorizationCodeResult]] | None = None,
409409
timeout: float = 300.0,
410410
jwt_parameters: JWTParameters | None = None,
411411
) -> None:

src/mcp/client/auth/oauth2.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,12 @@
3535
handle_token_response_scopes,
3636
is_valid_client_metadata_url,
3737
should_use_client_metadata_url,
38+
validate_authorization_response_iss,
39+
validate_metadata_issuer,
3840
)
3941
from mcp.client.streamable_http import MCP_PROTOCOL_VERSION
4042
from mcp.shared.auth import (
43+
AuthorizationCodeResult,
4144
OAuthClientInformationFull,
4245
OAuthClientMetadata,
4346
OAuthMetadata,
@@ -97,7 +100,7 @@ class OAuthContext:
97100
client_metadata: OAuthClientMetadata
98101
storage: TokenStorage
99102
redirect_handler: Callable[[str], Awaitable[None]] | None
100-
callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None
103+
callback_handler: Callable[[], Awaitable[AuthorizationCodeResult]] | None
101104
timeout: float = 300.0
102105
client_metadata_url: str | None = None
103106

@@ -227,7 +230,7 @@ def __init__(
227230
client_metadata: OAuthClientMetadata,
228231
storage: TokenStorage,
229232
redirect_handler: Callable[[str], Awaitable[None]] | None = None,
230-
callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None = None,
233+
callback_handler: Callable[[], Awaitable[AuthorizationCodeResult]] | None = None,
231234
timeout: float = 300.0,
232235
client_metadata_url: str | None = None,
233236
validate_resource_url: Callable[[str, str | None], Awaitable[None]] | None = None,
@@ -356,16 +359,19 @@ async def _perform_authorization_code_grant(self) -> tuple[str, str]:
356359
await self.context.redirect_handler(authorization_url)
357360

358361
# Wait for callback
359-
auth_code, returned_state = await self.context.callback_handler()
362+
result = await self.context.callback_handler()
360363

361-
if returned_state is None or not secrets.compare_digest(returned_state, state):
362-
raise OAuthFlowError(f"State parameter mismatch: {returned_state} != {state}")
364+
if result.state is None or not secrets.compare_digest(result.state, state):
365+
raise OAuthFlowError(f"State parameter mismatch: {result.state} != {state}")
363366

364-
if not auth_code:
367+
# RFC 9207: validate the authorization-response issuer
368+
validate_authorization_response_iss(result.iss, self.context.oauth_metadata)
369+
370+
if not result.code:
365371
raise OAuthFlowError("No authorization code received")
366372

367373
# Return auth code and code verifier for token exchange
368-
return auth_code, pkce_params.code_verifier
374+
return result.code, pkce_params.code_verifier
369375

370376
def _get_token_endpoint(self) -> str:
371377
if self.context.oauth_metadata and self.context.oauth_metadata.token_endpoint:
@@ -570,6 +576,9 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
570576
if not ok:
571577
break
572578
if ok and asm:
579+
# SEP-2468: metadata issuer must match the discovery issuer
580+
if self.context.auth_server_url is not None:
581+
validate_metadata_issuer(asm, self.context.auth_server_url)
573582
self.context.oauth_metadata = asm
574583
break
575584
else:

0 commit comments

Comments
 (0)