Skip to content

Commit 3ee84a9

Browse files
committed
feat(auth): add validate_registered_redirect_uri helper
1 parent 161834d commit 3ee84a9

2 files changed

Lines changed: 88 additions & 4 deletions

File tree

src/mcp/server/auth/routes.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from typing import Any
33
from urllib.parse import urlparse
44

5-
from pydantic import AnyHttpUrl
5+
from pydantic import AnyHttpUrl, AnyUrl
66
from starlette.middleware.cors import CORSMiddleware
77
from starlette.requests import Request
88
from starlette.responses import Response
@@ -18,7 +18,7 @@
1818
from mcp.server.auth.provider import OAuthAuthorizationServerProvider
1919
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
2020
from mcp.server.streamable_http import MCP_PROTOCOL_VERSION_HEADER
21-
from mcp.shared.auth import OAuthMetadata, ProtectedResourceMetadata
21+
from mcp.shared.auth import InvalidRedirectUriError, OAuthMetadata, ProtectedResourceMetadata
2222

2323

2424
def validate_issuer_url(url: AnyHttpUrl):
@@ -42,6 +42,33 @@ def validate_issuer_url(url: AnyHttpUrl):
4242
raise ValueError("Issuer URL must not have a query string")
4343

4444

45+
def validate_registered_redirect_uri(url: AnyUrl) -> None:
46+
"""Validate that a registered redirect_uri meets OAuth 2.0 + RFC 7591 requirements.
47+
48+
Mirrors the policy that :func:`validate_issuer_url` applies to issuer URLs:
49+
redirect URIs must use ``https``, except that ``http`` is permitted for
50+
loopback hosts (``localhost``, ``127.0.0.1``, ``[::1]``) per RFC 8252 §7.3,
51+
and they MUST NOT carry a fragment component per RFC 7591 §2.
52+
53+
Args:
54+
url: A registered redirect_uri value from
55+
:class:`mcp.shared.auth.OAuthClientMetadata`.
56+
57+
Raises:
58+
InvalidRedirectUriError: If the URI uses a scheme other than ``https``
59+
or loopback ``http``, or if it contains a fragment.
60+
"""
61+
# RFC 9700 §4.1.1 (OAuth 2.0 Security BCP): https-only, with the RFC 8252
62+
# native-app loopback exception.
63+
if url.scheme not in ("https", "http"):
64+
raise InvalidRedirectUriError(f"redirect_uri must use https (or http for loopback); got scheme {url.scheme!r}")
65+
if url.scheme == "http" and url.host not in ("localhost", "127.0.0.1", "[::1]"):
66+
raise InvalidRedirectUriError(f"redirect_uri must use https for non-loopback hosts; got {str(url)!r}")
67+
# RFC 7591 §2: redirect_uri MUST NOT contain a fragment component.
68+
if url.fragment:
69+
raise InvalidRedirectUriError(f"redirect_uri must not have a fragment; got {str(url)!r}")
70+
71+
4572
AUTHORIZATION_PATH = "/authorize"
4673
TOKEN_PATH = "/token"
4774
REGISTRATION_PATH = "/register"

tests/server/auth/test_routes.py

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import pytest
2-
from pydantic import AnyHttpUrl
2+
from pydantic import AnyHttpUrl, AnyUrl
33

4-
from mcp.server.auth.routes import validate_issuer_url
4+
from mcp.server.auth.routes import validate_issuer_url, validate_registered_redirect_uri
5+
from mcp.shared.auth import InvalidRedirectUriError
56

67

78
def test_validate_issuer_url_https_allowed():
@@ -45,3 +46,59 @@ def test_validate_issuer_url_fragment_rejected():
4546
def test_validate_issuer_url_query_rejected():
4647
with pytest.raises(ValueError, match="query"):
4748
validate_issuer_url(AnyHttpUrl("https://example.com/path?q=1"))
49+
50+
51+
def test_validate_registered_redirect_uri_https_allowed():
52+
validate_registered_redirect_uri(AnyUrl("https://example.com/cb"))
53+
54+
55+
def test_validate_registered_redirect_uri_https_with_query_allowed():
56+
validate_registered_redirect_uri(AnyUrl("https://example.com/cb?foo=bar"))
57+
58+
59+
def test_validate_registered_redirect_uri_http_localhost_allowed():
60+
validate_registered_redirect_uri(AnyUrl("http://localhost:8080/cb"))
61+
62+
63+
def test_validate_registered_redirect_uri_http_127_0_0_1_allowed():
64+
validate_registered_redirect_uri(AnyUrl("http://127.0.0.1:8080/cb"))
65+
66+
67+
def test_validate_registered_redirect_uri_http_ipv6_loopback_allowed():
68+
validate_registered_redirect_uri(AnyUrl("http://[::1]:8080/cb"))
69+
70+
71+
def test_validate_registered_redirect_uri_javascript_scheme_rejected():
72+
with pytest.raises(InvalidRedirectUriError, match="must use https"):
73+
validate_registered_redirect_uri(AnyUrl("javascript:alert(1)"))
74+
75+
76+
def test_validate_registered_redirect_uri_data_scheme_rejected():
77+
with pytest.raises(InvalidRedirectUriError, match="must use https"):
78+
validate_registered_redirect_uri(AnyUrl("data:text/html,x"))
79+
80+
81+
def test_validate_registered_redirect_uri_file_scheme_rejected():
82+
with pytest.raises(InvalidRedirectUriError, match="must use https"):
83+
validate_registered_redirect_uri(AnyUrl("file:///etc/passwd"))
84+
85+
86+
def test_validate_registered_redirect_uri_ftp_scheme_rejected():
87+
with pytest.raises(InvalidRedirectUriError, match="must use https"):
88+
validate_registered_redirect_uri(AnyUrl("ftp://attacker.example/cb"))
89+
90+
91+
def test_validate_registered_redirect_uri_http_non_loopback_rejected():
92+
with pytest.raises(InvalidRedirectUriError, match="must use https for non-loopback"):
93+
validate_registered_redirect_uri(AnyUrl("http://attacker.example/cb"))
94+
95+
96+
def test_validate_registered_redirect_uri_http_127_prefix_domain_rejected():
97+
"""A domain like 127.0.0.1.evil.com is NOT loopback."""
98+
with pytest.raises(InvalidRedirectUriError, match="must use https for non-loopback"):
99+
validate_registered_redirect_uri(AnyUrl("http://127.0.0.1.evil.com/cb"))
100+
101+
102+
def test_validate_registered_redirect_uri_fragment_rejected():
103+
with pytest.raises(InvalidRedirectUriError, match="must not have a fragment"):
104+
validate_registered_redirect_uri(AnyUrl("https://example.com/cb#frag"))

0 commit comments

Comments
 (0)