Skip to content

Commit 6689e0a

Browse files
committed
Don't broaden initial identity-assertion scope; normalize origin ports
Addresses the cubic review: - Scope no longer broadens on the initial exchange (P1). The previous union pulled the server-advertised scopes (which the base 401 scope-selection step writes onto client_metadata.scope) into the request, widening beyond what the caller asked for. The initial exchange now sends exactly the configured scope; only a 403 insufficient_scope step-up - distinguished by an already-issued token - unions in the challenged scope, so SEP-2350 escalation still works. - _origin normalizes the scheme's default port (P2), so an expected_issuer written with an explicit :443/:80 compares equal to the port-less token endpoint and a valid same-origin exchange is not rejected. Tests: initial-exchange-does-not-broaden, step-up-unions (now with a prior token present), and _origin default-port normalization.
1 parent 05f60ce commit 6689e0a

2 files changed

Lines changed: 66 additions & 9 deletions

File tree

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

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,18 @@
3232
JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"
3333

3434

35+
_DEFAULT_PORTS = {"https": 443, "http": 80}
36+
37+
3538
def _origin(url: str) -> tuple[str, str, int | None]:
36-
"""Return the (scheme, host, port) origin of a URL for same-origin comparison."""
39+
"""Return the (scheme, host, port) origin of a URL for same-origin comparison.
40+
41+
The port is normalized to the scheme's default so an explicit `:443`/`:80` compares equal to the
42+
same origin written without a port.
43+
"""
3744
parsed = urlsplit(url)
38-
return (parsed.scheme, parsed.hostname or "", parsed.port)
45+
port = parsed.port if parsed.port is not None else _DEFAULT_PORTS.get(parsed.scheme)
46+
return (parsed.scheme, parsed.hostname or "", port)
3947

4048

4149
class IdentityAssertionOAuthProvider(OAuthClientProvider):
@@ -196,13 +204,20 @@ async def _exchange_assertion(self) -> httpx.Request:
196204
if self.context.should_include_resource_param(self.context.protocol_version):
197205
token_data["resource"] = resource
198206

199-
# Honour the caller's requested scope, unioned with any scope the base flow selected or a
200-
# 403 step-up challenged in (SEP-2350). Write it back to client_metadata.scope so the base
201-
# _handle_token_response RFC 6749 §5.1 backfill records the same value on the stored token.
202-
scope = union_scopes(self._scopes, self.context.client_metadata.scope)
207+
# Scope handling. On the initial exchange send exactly the caller's requested scope: the
208+
# base 401 scope-selection step overwrites client_metadata.scope with the server-advertised
209+
# set, which must NOT silently broaden the request. On a 403 insufficient_scope step-up the
210+
# base flow re-runs this with client_metadata.scope already holding the SEP-2350 union of the
211+
# prior and challenged scopes; honour that so the retry actually escalates. The two are told
212+
# apart by whether a token already exists (a step-up only happens after one was issued).
213+
if self.context.current_tokens is None:
214+
scope = self._scopes
215+
else:
216+
scope = union_scopes(self._scopes, self.context.client_metadata.scope)
217+
# Write it back so the base _handle_token_response RFC 6749 §5.1 backfill records the same
218+
# value on the stored token rather than the server-advertised set.
203219
self.context.client_metadata.scope = scope
204220
if scope:
205221
token_data["scope"] = scope
206222

207-
token_url = self._get_token_endpoint()
208223
return httpx.Request("POST", token_url, data=token_data, headers=headers)

tests/client/auth/extensions/test_identity_assertion.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from mcp.client.auth.extensions.identity_assertion import (
88
JWT_BEARER_GRANT_TYPE,
99
IdentityAssertionOAuthProvider,
10+
_origin,
1011
)
1112
from mcp.shared.auth import (
1213
OAuthClientInformationFull,
@@ -192,12 +193,18 @@ async def test_missing_metadata_raises(mock_storage: MockTokenStorage) -> None:
192193

193194
@pytest.mark.anyio
194195
async def test_step_up_scope_is_unioned_with_configured_scope(mock_storage: MockTokenStorage) -> None:
195-
"""A 403 step-up challenge (written to client_metadata.scope) is unioned with the configured scope."""
196+
"""A 403 step-up challenge (written to client_metadata.scope) is unioned with the configured scope.
197+
198+
A step-up only happens after a token was issued, so the union branch is keyed on current_tokens
199+
being set; the test simulates both the existing token and the base flow's challenge write-back.
200+
"""
196201
provider = make_provider(mock_storage) # configured scopes="mcp"
197202
await provider._initialize()
198203
provider.context.oauth_metadata = oauth_metadata()
199204
provider.context.protocol_version = "2025-06-18"
200-
# Simulate the base 403 step-up writing the challenged scope onto client_metadata.scope.
205+
# A token exists (the one that drew the 403); the base step-up wrote the challenged union onto
206+
# client_metadata.scope.
207+
provider.context.current_tokens = OAuthToken(access_token="prior", scope="mcp")
201208
provider.context.client_metadata.scope = "files:write"
202209

203210
request = await provider._perform_authorization()
@@ -206,6 +213,29 @@ async def test_step_up_scope_is_unioned_with_configured_scope(mock_storage: Mock
206213
assert "scope=mcp files:write" in content
207214

208215

216+
@pytest.mark.anyio
217+
async def test_initial_exchange_does_not_broaden_to_server_scopes(mock_storage: MockTokenStorage) -> None:
218+
"""On the initial 401 exchange the request carries only the configured scope, not the server set.
219+
220+
The base 401 scope-selection step overwrites client_metadata.scope with server-advertised
221+
scopes; the provider must not silently broaden the caller's request with them.
222+
"""
223+
provider = make_provider(mock_storage) # configured scopes="mcp"
224+
await provider._initialize()
225+
provider.context.oauth_metadata = oauth_metadata()
226+
provider.context.protocol_version = "2025-06-18"
227+
# No token yet (initial exchange). The base flow advertised a broader set.
228+
assert provider.context.current_tokens is None
229+
provider.context.client_metadata.scope = "mcp extra admin"
230+
231+
request = await provider._perform_authorization()
232+
233+
content = urllib.parse.unquote_plus(request.content.decode())
234+
assert "scope=mcp&" in content or content.endswith("scope=mcp")
235+
assert "extra" not in content
236+
assert "admin" not in content
237+
238+
209239
def test_empty_client_secret_is_rejected(mock_storage: MockTokenStorage) -> None:
210240
"""SEP-990 mandates a confidential client, so an empty client_secret is refused at construction."""
211241

@@ -277,3 +307,15 @@ async def test_resource_match_accepts_prm_naming_the_expected_as(mock_storage: M
277307
)
278308

279309
await provider._validate_resource_match(prm) # does not raise
310+
311+
312+
def test_origin_normalizes_default_ports() -> None:
313+
"""`_origin` treats an explicit scheme-default port as equal to the port-less form.
314+
315+
The token_endpoint (an AnyHttpUrl) has its default port normalized away, but a caller may
316+
configure expected_issuer with the port spelled out; the origin check must not reject that.
317+
"""
318+
assert _origin("https://host") == _origin("https://host:443")
319+
assert _origin("http://host") == _origin("http://host:80")
320+
assert _origin("https://host") != _origin("https://host:8443")
321+
assert _origin("https://host") != _origin("https://other")

0 commit comments

Comments
 (0)