Skip to content

Commit 5dfcaea

Browse files
committed
Reject non-HTTPS, non-loopback redirect URIs at client registration
Two fixes to the optional bundled OAuth authorization server (the `auth_server_provider=` path). The registration endpoint accepted any well-formed URL as a `redirect_uris` entry: cleartext `http://` on a non-loopback host, `javascript:`, `data:`, and URIs carrying a fragment all registered successfully. The MCP authorization specification's Communication Security section requires every redirect URI to be either localhost or HTTPS, and OAuth 2.1 section 2.3 forbids a fragment component. Such an entry is now rejected with `400 invalid_client_metadata`. Loopback is exactly the three forms OAuth 2.1 section 8.4.2 names (`localhost`, `127.0.0.1`, `[::1]`), on any port; query strings remain permitted. This also rejects RFC 8252 private-use schemes such as `com.example.app:/callback`: MCP restricts redirect URIs to HTTPS or loopback, with no carve-out for native apps. The rule lives on the request model: `RegistrationRequest`, until now a dead alias of `OAuthClientMetadata`, becomes a real subclass with a `redirect_uris` field validator, so a forbidden URI fails parsing and takes the handler's existing `invalid_client_metadata` arm rather than needing a post-parse check. `OAuthClientMetadata` itself is unchanged: the client also serializes it when registering against third-party authorization servers whose redirect-URI policies the SDK does not own. The loopback host set moves to a single `LOOPBACK_HOSTS` constant in `mcp.server.auth.provider`, shared with `validate_issuer_url`, which previously inlined the same tuple. Separately, the token endpoint now answers an authorization-code exchange whose `redirect_uri` does not match the one used at `/authorize` with `error=invalid_grant` instead of `invalid_request`. RFC 6749 section 5.2 assigns this case to `invalid_grant` ("does not match the redirection URI used in the authorization request"), and the handler's other authorization-code failures already use it. The exchange was already rejected with HTTP 400; only the `error` field changes. Update the affected tests and the interaction-requirement entries, and add a migration note. Closes #2629
1 parent 660407a commit 5dfcaea

8 files changed

Lines changed: 132 additions & 39 deletions

File tree

docs/migration.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1420,6 +1420,14 @@ Leaving `resource_server_url=None` continues to disable the check entirely (ther
14201420

14211421
`RefreshToken` gains an optional `resource` field so an `OAuthAuthorizationServerProvider` can propagate the original grant's audience binding through `exchange_refresh_token`; without it a refreshed access token would carry no audience and be rejected. `BearerAuthBackend.__init__` gains a keyword-only `resource_server_url: AnyHttpUrl | None = None`, wired automatically from `AuthSettings.enforced_audience`; `None` (the default, and what the SDK passes when `verifier_validates_audience` is set) means no audience is enforced.
14221422

1423+
### Bundled authorization server: RFC-correct redirect-URI handling
1424+
1425+
Two fixes to the optional bundled OAuth authorization server (the `auth_server_provider=` path).
1426+
1427+
The token endpoint now answers an authorization-code exchange whose `redirect_uri` does not match the one used at `/authorize` with `error=invalid_grant` instead of `error=invalid_request`. RFC 6749 §5.2 assigns this case to `invalid_grant` ("does not match the redirection URI used in the authorization request"). The exchange was already rejected with HTTP 400; only the `error` field changes.
1428+
1429+
The registration endpoint now rejects a `redirect_uris` entry that is neither HTTPS nor a loopback host (`localhost`, `127.0.0.1`, or `[::1]`) with `400 invalid_client_metadata`. Previously any well-formed URL — including cleartext `http://` on a non-loopback host, `javascript:`, and `data:` — was accepted and stored. The MCP authorization specification's Communication Security section requires every redirect URI to be either `localhost` or HTTPS; the SDK accepts the three loopback forms OAuth 2.1 §8.4.2 names. Local development against `http://localhost:*`, `http://127.0.0.1:*`, or `http://[::1]:*` is unaffected. Note that this also rejects RFC 8252 private-use URI schemes (such as `com.example.app:/callback`): MCP restricts redirect URIs to HTTPS or loopback, which is stricter than vanilla OAuth allows for native apps. A redirect URI carrying a fragment component is also rejected (OAuth 2.1 §2.3). Query strings remain permitted.
1430+
14231431
### Lowlevel `Server`: `subscribe` capability now correctly reported
14241432

14251433
Previously, the lowlevel `Server` hardcoded `subscribe=False` in resource capabilities even when a `subscribe_resource()` handler was registered. The `subscribe` capability is now dynamically set to `True` when an `on_subscribe_resource` handler is provided. Clients that previously didn't see `subscribe: true` in capabilities will now see it when a handler is registered, which may change client behavior.

src/mcp/server/auth/handlers/register.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,43 @@
44
from typing import Any
55
from uuid import uuid4
66

7-
from pydantic import BaseModel, ValidationError
7+
from pydantic import AnyUrl, BaseModel, ValidationError, field_validator
88
from starlette.requests import Request
99
from starlette.responses import Response
1010

1111
from mcp.server.auth.errors import stringify_pydantic_error
1212
from mcp.server.auth.json_response import PydanticJSONResponse
13-
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, RegistrationError, RegistrationErrorCode
13+
from mcp.server.auth.provider import (
14+
LOOPBACK_HOSTS,
15+
OAuthAuthorizationServerProvider,
16+
RegistrationError,
17+
RegistrationErrorCode,
18+
)
1419
from mcp.server.auth.settings import ClientRegistrationOptions
1520
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
1621

17-
# this alias is a no-op; it's just to separate out the types exposed to the
18-
# provider from what we use in the HTTP handler
19-
RegistrationRequest = OAuthClientMetadata
22+
23+
class RegistrationRequest(OAuthClientMetadata):
24+
"""The registration endpoint's inbound client metadata, with server-side redirect-URI policy.
25+
26+
The MCP authorization spec requires every redirect URI to use HTTPS or target a loopback
27+
host, and OAuth 2.1 section 2.3 forbids a fragment component, so a request carrying a URI
28+
that violates either fails validation and never reaches the provider. The base
29+
`OAuthClientMetadata` stays permissive: the client also serializes it when registering
30+
against third-party authorization servers whose redirect-URI policies the SDK does not own.
31+
"""
32+
33+
@field_validator("redirect_uris", mode="after")
34+
@classmethod
35+
def _https_or_loopback_without_fragment(cls, v: list[AnyUrl] | None) -> list[AnyUrl] | None:
36+
# None and an empty list both mean there is nothing to check.
37+
for uri in v or []:
38+
if uri.scheme != "https" and uri.host not in LOOPBACK_HOSTS:
39+
raise ValueError(f"redirect_uri must use https or target a loopback host: {uri}")
40+
# `is not None`, not truthiness: a bare `https://x/cb#` parses with fragment == "".
41+
if uri.fragment is not None:
42+
raise ValueError(f"redirect_uri must not include a fragment: {uri}")
43+
return v
2044

2145

2246
class RegistrationErrorResponse(BaseModel):
@@ -33,7 +57,7 @@ async def handle(self, request: Request) -> Response:
3357
# Implements dynamic client registration as defined in https://datatracker.ietf.org/doc/html/rfc7591#section-3.1
3458
try:
3559
body = await request.body()
36-
client_metadata = OAuthClientMetadata.model_validate_json(body)
60+
client_metadata = RegistrationRequest.model_validate_json(body)
3761

3862
# Scope validation is handled below
3963
except ValidationError as validation_error:

src/mcp/server/auth/handlers/token.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ async def handle(self, request: Request):
154154
if token_redirect_str != auth_redirect_str:
155155
return self.response(
156156
TokenErrorResponse(
157-
error="invalid_request",
157+
error="invalid_grant",
158158
error_description=("redirect_uri did not match the one used when creating auth code"),
159159
)
160160
)

src/mcp/server/auth/provider.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66

77
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
88

9+
# OAuth 2.1 §8.4.2: the loopback IP literal `127.0.0.1` or `[::1]`, or the hostname `localhost`.
10+
# Spelled as pydantic's `AnyUrl.host` reports them (an IPv6 literal keeps its brackets).
11+
LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "[::1]"})
12+
913

1014
class AuthorizationParams(BaseModel):
1115
state: str | None

src/mcp/server/auth/routes.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from mcp.server.auth.handlers.revoke import RevocationHandler
1616
from mcp.server.auth.handlers.token import TokenHandler
1717
from mcp.server.auth.middleware.client_auth import ClientAuthenticator
18-
from mcp.server.auth.provider import OAuthAuthorizationServerProvider
18+
from mcp.server.auth.provider import LOOPBACK_HOSTS, OAuthAuthorizationServerProvider
1919
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
2020
from mcp.shared.auth import OAuthMetadata, ProtectedResourceMetadata
2121
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
@@ -32,7 +32,7 @@ def validate_issuer_url(url: AnyHttpUrl):
3232
"""
3333

3434
# RFC 8414 requires HTTPS, but we allow loopback/localhost HTTP for testing
35-
if url.scheme != "https" and url.host not in ("localhost", "127.0.0.1", "[::1]"):
35+
if url.scheme != "https" and url.host not in LOOPBACK_HOSTS:
3636
raise ValueError("Issuer URL must be HTTPS")
3737

3838
# No fragments or query parameters allowed

tests/interaction/_requirements.py

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2733,28 +2733,15 @@ def __post_init__(self) -> None:
27332733
),
27342734
transports=("streamable-http",),
27352735
note="Auth is enforced at the HTTP layer; the bundled AS is an ASGI app.",
2736-
divergence=Divergence(
2737-
note=(
2738-
"RFC 6749 §5.2 assigns redirect_uri mismatch at the token endpoint to invalid_grant; "
2739-
"the SDK's TokenHandler returns invalid_request (src/mcp/server/auth/handlers/token.py:157). "
2740-
"The rejection itself is the security-relevant property and is correct."
2741-
),
2742-
),
27432736
),
27442737
"hosting:auth:as:redirect-uri-scheme": Requirement(
27452738
source=f"{SPEC_BASE_URL}/basic/authorization#communication-security",
27462739
behavior=(
2747-
"The bundled registration endpoint accepts only redirect URIs that use HTTPS or target a loopback host."
2740+
"The bundled registration endpoint accepts only redirect URIs that use HTTPS or target a loopback "
2741+
"host (`localhost`, `127.0.0.1`, `[::1]`), and that carry no fragment component."
27482742
),
27492743
transports=("streamable-http",),
27502744
note="Auth is enforced at the HTTP layer; the bundled AS is an ASGI app.",
2751-
divergence=Divergence(
2752-
note=(
2753-
"Not enforced: the registration handler models redirect_uris as AnyUrl with no scheme or "
2754-
"host check, so http://evil.example/callback is accepted and registered. The spec's "
2755-
"localhost-or-HTTPS rule is left to the provider implementation."
2756-
),
2757-
),
27582745
),
27592746
"hosting:auth:as:token-cache-headers": Requirement(
27602747
source="sdk",

tests/interaction/auth/test_as_handlers.py

Lines changed: 84 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -170,16 +170,15 @@ async def test_reusing_an_authorization_code_is_rejected_with_invalid_grant(
170170

171171

172172
@requirement("hosting:auth:as:redirect-uri-binding")
173-
async def test_a_redirect_uri_differing_from_authorize_is_rejected_at_the_token_endpoint(
173+
async def test_a_token_exchange_with_a_mismatched_redirect_uri_is_rejected_with_invalid_grant(
174174
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
175175
) -> None:
176-
"""A token exchange whose `redirect_uri` differs from the one used at authorize is rejected.
176+
"""A token exchange whose `redirect_uri` differs from the one used at authorize is rejected with `invalid_grant`.
177177
178178
This is the security-critical half of redirect-URI binding: a code intercepted via redirect
179179
substitution cannot be redeemed because the attacker cannot reproduce the original authorize
180-
redirect URI at the token endpoint. RFC 6749 §5.2 specifies `invalid_grant` for this case;
181-
the SDK returns `invalid_request` (see the divergence on the requirement). The rejection
182-
itself is the security property and is correct.
180+
redirect URI at the token endpoint. RFC 6749 §5.2 assigns the mismatch to `invalid_grant`,
181+
matching the handler's other authorization-code failures.
183182
"""
184183
http, _ = as_app
185184
client_info, code, verifier = await _mint_code(http)
@@ -192,7 +191,7 @@ async def test_a_redirect_uri_differing_from_authorize_is_rejected_at_the_token_
192191
assert response.status_code == 400
193192
assert response.json() == snapshot(
194193
{
195-
"error": "invalid_request",
194+
"error": "invalid_grant",
196195
"error_description": "redirect_uri did not match the one used when creating auth code",
197196
}
198197
)
@@ -279,22 +278,93 @@ async def test_authorize_with_an_unregistered_redirect_uri_is_rejected_directly(
279278

280279

281280
@requirement("hosting:auth:as:redirect-uri-scheme")
282-
async def test_a_non_loopback_http_redirect_uri_is_accepted_at_registration(
283-
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
281+
@pytest.mark.parametrize(
282+
"redirect_uri",
283+
[
284+
"http://evil.example/callback",
285+
"http://localhost.evil.example/callback",
286+
"javascript:alert(1)",
287+
"com.example.app:/oauth/cb",
288+
],
289+
)
290+
async def test_a_redirect_uri_that_is_neither_https_nor_loopback_is_rejected_at_registration(
291+
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider], redirect_uri: str
292+
) -> None:
293+
"""A registration whose redirect URI is neither HTTPS nor a loopback host is rejected with 400.
294+
295+
The spec requires every redirect URI to be either HTTPS or a loopback host; the
296+
registration request model enforces this at parse time so the provider never sees the
297+
client. Loopback is matched on the whole host (`localhost.evil.example` is not loopback),
298+
and a scheme with no authority — `javascript:`, or an RFC 8252 private-use scheme such as
299+
`com.example.app:` — fails the same check.
300+
"""
301+
http, provider = as_app
302+
body = oauth_client_metadata().model_dump(mode="json", exclude_none=True)
303+
body["redirect_uris"] = [redirect_uri]
304+
305+
response = await http.post("/register", json=body)
306+
307+
assert response.status_code == 400
308+
error = response.json()
309+
assert error["error"] == "invalid_client_metadata"
310+
# Pydantic frames the validator's message as `redirect_uris: Value error, <msg>` (third-party
311+
# text), so assert only the SDK-authored sentence to pin which validation fired.
312+
assert "redirect_uri must use https or target a loopback host" in error["error_description"]
313+
assert provider.clients == {}
314+
315+
316+
@requirement("hosting:auth:as:redirect-uri-scheme")
317+
@pytest.mark.parametrize(
318+
"redirect_uri",
319+
[
320+
"https://app.example.com/callback",
321+
"http://localhost:3030/callback",
322+
"http://127.0.0.1:8000/callback",
323+
"http://[::1]:8000/callback",
324+
],
325+
)
326+
async def test_an_https_or_loopback_redirect_uri_is_accepted_at_registration(
327+
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider], redirect_uri: str
284328
) -> None:
285-
"""A registration carrying a non-HTTPS, non-loopback redirect URI is accepted.
329+
"""A registration whose redirect URI uses HTTPS or targets a loopback host is accepted and stored.
286330
287-
The spec requires every redirect URI to be either HTTPS or a loopback host; the bundled
288-
registration handler does not enforce this and registers `http://evil.example/callback`
289-
successfully. See the divergence on the requirement.
331+
Loopback covers exactly the three forms OAuth 2.1 names: the hostname `localhost` and the
332+
loopback IP literals `127.0.0.1` and `[::1]`, on any port, over plain HTTP.
290333
"""
291334
http, provider = as_app
292335
body = oauth_client_metadata().model_dump(mode="json", exclude_none=True)
293-
body["redirect_uris"] = ["http://evil.example/callback"]
336+
body["redirect_uris"] = [redirect_uri]
294337

295338
response = await http.post("/register", json=body)
296339

297340
assert response.status_code == 201
298341
info = OAuthClientInformationFull.model_validate_json(response.content)
299-
assert [str(u) for u in (info.redirect_uris or [])] == ["http://evil.example/callback"]
342+
assert [str(u) for u in (info.redirect_uris or [])] == [redirect_uri]
300343
assert info.client_id in provider.clients
344+
345+
346+
@requirement("hosting:auth:as:redirect-uri-scheme")
347+
@pytest.mark.parametrize(
348+
"redirect_uri", ["https://app.example.com/callback#", "https://app.example.com/callback#nonce"]
349+
)
350+
async def test_a_redirect_uri_carrying_a_fragment_is_rejected_at_registration(
351+
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider], redirect_uri: str
352+
) -> None:
353+
"""A registration whose redirect URI carries a fragment component is rejected with 400.
354+
355+
OAuth 2.1 section 2.3: a redirect URI MUST NOT include a fragment component. The bare
356+
trailing `#` parses to an empty-string fragment and is rejected the same as a named one.
357+
"""
358+
http, provider = as_app
359+
body = oauth_client_metadata().model_dump(mode="json", exclude_none=True)
360+
body["redirect_uris"] = [redirect_uri]
361+
362+
response = await http.post("/register", json=body)
363+
364+
assert response.status_code == 400
365+
error = response.json()
366+
assert error["error"] == "invalid_client_metadata"
367+
# Pydantic frames the validator's message as `redirect_uris: Value error, <msg>` (third-party
368+
# text), so assert only the SDK-authored sentence to pin which validation fired.
369+
assert "redirect_uri must not include a fragment" in error["error_description"]
370+
assert provider.clients == {}

tests/server/mcpserver/auth/test_auth_integration.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -501,7 +501,7 @@ async def test_token_redirect_uri_mismatch(
501501
)
502502
assert response.status_code == 400
503503
error_response = response.json()
504-
assert error_response["error"] == "invalid_request"
504+
assert error_response["error"] == "invalid_grant"
505505
assert "redirect_uri did not match" in error_response["error_description"]
506506

507507
@pytest.mark.anyio

0 commit comments

Comments
 (0)