Skip to content

Commit 93f3bfc

Browse files
committed
Normalize registered OAuth redirect URL types
1 parent 91941ed commit 93f3bfc

3 files changed

Lines changed: 53 additions & 5 deletions

File tree

‎docs/client/oauth-clients.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ Nothing else in the file mentions OAuth. `main()` never sees a token.
2525

2626
`OAuthClientMetadata` is the real [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591) registration document, as a Pydantic model.
2727

28+
You can pass URL strings or Pydantic URL subclasses in `redirect_uris`. The SDK stores them as `AnyUrl` values without changing their serialized URI.
29+
2830
You set three fields. The defaults fill in the rest: `grant_types` is already `["authorization_code", "refresh_token"]` and `response_types` is already `["code"]`, which is exactly the flow this provider runs.
2931

3032
!!! check

‎src/mcp/shared/auth.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
from typing import Any, Literal, cast
1+
from typing import Annotated, Any, Literal, cast
22

3-
from pydantic import AnyHttpUrl, AnyUrl, BaseModel, ConfigDict, Field, field_validator, model_validator
3+
from pydantic import AfterValidator, AnyHttpUrl, AnyUrl, BaseModel, ConfigDict, Field, field_validator, model_validator
44

55
# RFC 7523 JWT bearer grant; SEP-990 leg 2 uses this to present the ID-JAG.
66
JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"
@@ -115,7 +115,8 @@ class OAuthClientMetadata(OAuthClientMetadataBase):
115115
job. See https://datatracker.ietf.org/doc/html/rfc7591#section-2
116116
"""
117117

118-
redirect_uris: list[AnyUrl] | None = Field(..., min_length=1)
118+
# Pydantic retains URL subclasses, whose equality differs from AnyUrl.
119+
redirect_uris: list[Annotated[AnyUrl, AfterValidator(AnyUrl)]] | None = Field(..., min_length=1)
119120
# supported auth methods for the token endpoint
120121
token_endpoint_auth_method: TokenEndpointAuthMethod | None = None
121122
# supported grant_types of this implementation
@@ -143,7 +144,7 @@ class OAuthClientInformationFull(OAuthClientMetadataBase):
143144
them against a client's requested `redirect_uri`.
144145
"""
145146

146-
redirect_uris: list[AnyUrl] | None = None
147+
redirect_uris: list[Annotated[AnyUrl, AfterValidator(AnyUrl)]] | None = None
147148
# RFC 7591 §3.2.1: the server may assign an auth method other than the one requested,
148149
# including methods this SDK does not implement, or omit it.
149150
token_endpoint_auth_method: str | None = None

‎tests/shared/test_auth.py‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Tests for OAuth 2.0 shared code."""
22

33
import pytest
4-
from pydantic import AnyUrl, ValidationError
4+
from pydantic import AnyHttpUrl, AnyUrl, ValidationError, create_model
55

66
from mcp.shared.auth import InvalidRedirectUriError, OAuthClientInformationFull, OAuthClientMetadata, OAuthMetadata
77

@@ -218,6 +218,51 @@ def test_client_with_no_registered_redirect_uris_cannot_resolve_a_redirect(
218218
info.validate_redirect_uri(redirect_uri)
219219

220220

221+
@pytest.mark.parametrize("model", [OAuthClientMetadata, OAuthClientInformationFull])
222+
@pytest.mark.parametrize("container", [list, tuple])
223+
def test_redirect_uri_subtypes_are_stored_as_any_url(
224+
model: type[OAuthClientMetadata] | type[OAuthClientInformationFull],
225+
container: type[list[AnyHttpUrl]] | type[tuple[AnyHttpUrl, ...]],
226+
) -> None:
227+
"""SDK-defined registration models compare a URL subclass with the incoming base URL."""
228+
url = "https://example.com/callback"
229+
data: dict[str, object] = {"redirect_uris": container([AnyHttpUrl(url)])}
230+
if model is OAuthClientInformationFull:
231+
data["client_id"] = "abc123"
232+
info = model.model_validate(data, strict=container is list)
233+
234+
assert info.redirect_uris == [AnyUrl(url)]
235+
assert info.model_dump(mode="json")["redirect_uris"] == [url]
236+
if isinstance(info, OAuthClientInformationFull):
237+
assert info.validate_redirect_uri(AnyUrl(url)) == AnyUrl(url)
238+
with pytest.raises(InvalidRedirectUriError):
239+
info.validate_redirect_uri(AnyUrl("https://example.com/other"))
240+
241+
242+
@pytest.mark.parametrize("model", [OAuthClientMetadata, OAuthClientInformationFull])
243+
def test_redirect_uri_normalization_preserves_strict_list_validation(
244+
model: type[OAuthClientMetadata] | type[OAuthClientInformationFull],
245+
) -> None:
246+
"""SDK-defined strict parsing still rejects non-list redirect URI containers."""
247+
data: dict[str, object] = {"redirect_uris": (AnyHttpUrl("https://example.com/callback"),)}
248+
if model is OAuthClientInformationFull:
249+
data["client_id"] = "abc123"
250+
251+
with pytest.raises(ValidationError):
252+
model.model_validate(data, strict=True)
253+
254+
255+
def test_redeclared_redirect_uri_field_keeps_its_own_validation() -> None:
256+
"""SDK-defined URL normalization does not override a subclass's replacement field."""
257+
258+
CustomClientInformation = create_model(
259+
"CustomClientInformation", __base__=OAuthClientInformationFull, redirect_uris=(list[str] | None, None)
260+
)
261+
info = CustomClientInformation.model_validate({"client_id": "abc123", "redirect_uris": ["custom redirect"]})
262+
263+
assert info.redirect_uris == ["custom redirect"]
264+
265+
221266
def test_request_metadata_restricts_application_type_to_the_values_the_sdk_sends():
222267
"""What the SDK sends stays narrow even though what it accepts back is wide."""
223268
with pytest.raises(ValidationError):

0 commit comments

Comments
 (0)