Skip to content

Commit 7f8a010

Browse files
authored
Move TokenHandler to OAuthProvider for consistent error codes (#2538)
All OAuth providers now return correct invalid_client error codes instead of unauthorized_client for auth failures. Previously only OAuthProxy had this fix; now OAuthProvider (and InMemoryOAuthProvider) also benefit.
1 parent 97438db commit 7f8a010

File tree

3 files changed

+79
-83
lines changed

3 files changed

+79
-83
lines changed

src/fastmcp/server/auth/auth.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
from __future__ import annotations
22

3+
import json
34
from typing import Any, cast
45
from urllib.parse import urlparse
56

7+
from mcp.server.auth.handlers.token import TokenErrorResponse
8+
from mcp.server.auth.handlers.token import TokenHandler as _SDKTokenHandler
9+
from mcp.server.auth.json_response import PydanticJSONResponse
610
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
711
from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend
12+
from mcp.server.auth.middleware.client_auth import ClientAuthenticator
813
from mcp.server.auth.provider import (
914
AccessToken as _SDKAccessToken,
1015
)
@@ -17,6 +22,7 @@
1722
TokenVerifier as TokenVerifierProtocol,
1823
)
1924
from mcp.server.auth.routes import (
25+
cors_middleware,
2026
create_auth_routes,
2127
create_protected_resource_routes,
2228
)
@@ -40,6 +46,48 @@ class AccessToken(_SDKAccessToken):
4046
claims: dict[str, Any] = Field(default_factory=dict)
4147

4248

49+
class TokenHandler(_SDKTokenHandler):
50+
"""TokenHandler that returns OAuth 2.1 compliant error responses.
51+
52+
The MCP SDK returns `unauthorized_client` for client authentication failures.
53+
However, per RFC 6749 Section 5.2, authentication failures should return
54+
`invalid_client` with HTTP 401, not `unauthorized_client`.
55+
56+
This distinction matters: `unauthorized_client` means "client exists but
57+
can't do this", while `invalid_client` means "client doesn't exist or
58+
credentials are wrong". Claude's OAuth client uses this to decide whether
59+
to re-register.
60+
61+
This handler transforms 401 responses with `unauthorized_client` to use
62+
`invalid_client` instead, making the error semantics correct per OAuth spec.
63+
"""
64+
65+
async def handle(self, request: Any):
66+
"""Wrap SDK handle() and transform auth error responses."""
67+
response = await super().handle(request)
68+
69+
# Transform 401 unauthorized_client -> invalid_client
70+
if response.status_code == 401:
71+
try:
72+
body = json.loads(response.body)
73+
if body.get("error") == "unauthorized_client":
74+
return PydanticJSONResponse(
75+
content=TokenErrorResponse(
76+
error="invalid_client",
77+
error_description=body.get("error_description"),
78+
),
79+
status_code=401,
80+
headers={
81+
"Cache-Control": "no-store",
82+
"Pragma": "no-cache",
83+
},
84+
)
85+
except (json.JSONDecodeError, AttributeError):
86+
pass # Not JSON or unexpected format, return as-is
87+
88+
return response
89+
90+
4391
class AuthProvider(TokenVerifierProtocol):
4492
"""Base class for all FastMCP authentication providers.
4593
@@ -368,14 +416,40 @@ def get_routes(
368416
self.issuer_url is not None
369417
) # typing check (issuer_url defaults to base_url)
370418

371-
oauth_routes = create_auth_routes(
419+
sdk_routes = create_auth_routes(
372420
provider=self,
373421
issuer_url=self.base_url,
374422
service_documentation_url=self.service_documentation_url,
375423
client_registration_options=self.client_registration_options,
376424
revocation_options=self.revocation_options,
377425
)
378426

427+
# Replace the token endpoint with our custom handler that returns
428+
# proper OAuth 2.1 error codes (invalid_client instead of unauthorized_client)
429+
oauth_routes: list[Route] = []
430+
for route in sdk_routes:
431+
if (
432+
isinstance(route, Route)
433+
and route.path == "/token"
434+
and route.methods is not None
435+
and "POST" in route.methods
436+
):
437+
# Replace with our OAuth 2.1 compliant token handler
438+
token_handler = TokenHandler(
439+
provider=self, client_authenticator=ClientAuthenticator(self)
440+
)
441+
oauth_routes.append(
442+
Route(
443+
path="/token",
444+
endpoint=cors_middleware(
445+
token_handler.handle, ["POST", "OPTIONS"]
446+
),
447+
methods=["POST", "OPTIONS"],
448+
)
449+
)
450+
else:
451+
oauth_routes.append(route)
452+
379453
# Get the resource URL based on the MCP path
380454
resource_url = self._get_resource_url(mcp_path)
381455

src/fastmcp/server/auth/oauth_proxy.py

Lines changed: 1 addition & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,6 @@
3636
from key_value.aio.protocols import AsyncKeyValue
3737
from key_value.aio.stores.disk import DiskStore
3838
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
39-
from mcp.server.auth.handlers.token import TokenErrorResponse
40-
from mcp.server.auth.handlers.token import TokenHandler as _SDKTokenHandler
41-
from mcp.server.auth.json_response import PydanticJSONResponse
42-
from mcp.server.auth.middleware.client_auth import ClientAuthenticator
4339
from mcp.server.auth.provider import (
4440
AccessToken,
4541
AuthorizationCode,
@@ -48,7 +44,6 @@
4844
RefreshToken,
4945
TokenError,
5046
)
51-
from mcp.server.auth.routes import cors_middleware
5247
from mcp.server.auth.settings import (
5348
ClientRegistrationOptions,
5449
RevocationOptions,
@@ -514,53 +509,6 @@ def create_error_html(
514509
)
515510

516511

517-
# -------------------------------------------------------------------------
518-
# Handler Classes
519-
# -------------------------------------------------------------------------
520-
521-
522-
class TokenHandler(_SDKTokenHandler):
523-
"""TokenHandler that returns OAuth 2.1 compliant error responses.
524-
525-
The MCP SDK returns `unauthorized_client` for client authentication failures.
526-
However, per RFC 6749 Section 5.2, authentication failures should return
527-
`invalid_client` with HTTP 401, not `unauthorized_client`.
528-
529-
This distinction matters: `unauthorized_client` means "client exists but
530-
can't do this", while `invalid_client` means "client doesn't exist or
531-
credentials are wrong". Claude's OAuth client uses this to decide whether
532-
to re-register.
533-
534-
This handler transforms 401 responses with `unauthorized_client` to use
535-
`invalid_client` instead, making the error semantics correct per OAuth spec.
536-
"""
537-
538-
async def handle(self, request: Any):
539-
"""Wrap SDK handle() and transform auth error responses."""
540-
response = await super().handle(request)
541-
542-
# Transform 401 unauthorized_client -> invalid_client
543-
if response.status_code == 401:
544-
try:
545-
body = json.loads(response.body)
546-
if body.get("error") == "unauthorized_client":
547-
return PydanticJSONResponse(
548-
content=TokenErrorResponse(
549-
error="invalid_client",
550-
error_description=body.get("error_description"),
551-
),
552-
status_code=401,
553-
headers={
554-
"Cache-Control": "no-store",
555-
"Pragma": "no-cache",
556-
},
557-
)
558-
except (json.JSONDecodeError, AttributeError):
559-
pass # Not JSON or unexpected format, return as-is
560-
561-
return response
562-
563-
564512
class OAuthProxy(OAuthProvider):
565513
"""OAuth provider that presents a DCR-compliant interface while proxying to non-DCR IDPs.
566514
@@ -1668,10 +1616,9 @@ def get_routes(
16681616
This is used to advertise the resource URL in metadata.
16691617
"""
16701618
# Get standard OAuth routes from parent class
1619+
# Note: parent already replaces /token with TokenHandler for proper error codes
16711620
routes = super().get_routes(mcp_path)
16721621
custom_routes = []
1673-
token_route_found = False
1674-
authorize_route_found = False
16751622

16761623
logger.debug(
16771624
f"get_routes called - configuring OAuth routes in {len(routes)} routes"
@@ -1689,7 +1636,6 @@ def get_routes(
16891636
and route.methods is not None
16901637
and ("GET" in route.methods or "POST" in route.methods)
16911638
):
1692-
authorize_route_found = True
16931639
# Replace with our enhanced authorization handler
16941640
# Note: self.base_url is guaranteed to be set in parent __init__
16951641
authorize_handler = AuthorizationHandler(
@@ -1705,27 +1651,6 @@ def get_routes(
17051651
methods=["GET", "POST"],
17061652
)
17071653
)
1708-
# Replace the token endpoint with our custom handler that returns proper OAuth 2.1 error codes
1709-
elif (
1710-
isinstance(route, Route)
1711-
and route.path == "/token"
1712-
and route.methods is not None
1713-
and "POST" in route.methods
1714-
):
1715-
token_route_found = True
1716-
# Replace with our OAuth 2.1 compliant token handler
1717-
token_handler = TokenHandler(
1718-
provider=self, client_authenticator=ClientAuthenticator(self)
1719-
)
1720-
custom_routes.append(
1721-
Route(
1722-
path="/token",
1723-
endpoint=cors_middleware(
1724-
token_handler.handle, ["POST", "OPTIONS"]
1725-
),
1726-
methods=["POST", "OPTIONS"],
1727-
)
1728-
)
17291654
else:
17301655
# Keep all other standard OAuth routes unchanged
17311656
custom_routes.append(route)
@@ -1747,9 +1672,6 @@ def get_routes(
17471672
)
17481673
)
17491674

1750-
logger.debug(
1751-
f"✅ OAuth routes configured: authorize_endpoint={authorize_route_found}, token_endpoint={token_route_found}, total routes={len(custom_routes)} (includes OAuth callback + consent)"
1752-
)
17531675
return custom_routes
17541676

17551677
# -------------------------------------------------------------------------

tests/server/auth/test_oauth_proxy.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1254,7 +1254,7 @@ async def test_transforms_client_auth_failure_to_invalid_client_401(self):
12541254

12551255
from mcp.server.auth.handlers.token import TokenHandler as SDKTokenHandler
12561256

1257-
from fastmcp.server.auth.oauth_proxy import TokenHandler
1257+
from fastmcp.server.auth.auth import TokenHandler
12581258

12591259
handler = TokenHandler(provider=Mock(), client_authenticator=Mock())
12601260

@@ -1283,7 +1283,7 @@ def test_does_not_transform_grant_type_unauthorized_to_invalid_client(self):
12831283
"""Test that grant type authorization errors stay as unauthorized_client with 400."""
12841284
from mcp.server.auth.handlers.token import TokenErrorResponse
12851285

1286-
from fastmcp.server.auth.oauth_proxy import TokenHandler
1286+
from fastmcp.server.auth.auth import TokenHandler
12871287

12881288
handler = TokenHandler(provider=Mock(), client_authenticator=Mock())
12891289

@@ -1303,7 +1303,7 @@ def test_does_not_transform_other_errors(self):
13031303
"""Test that other error types pass through unchanged."""
13041304
from mcp.server.auth.handlers.token import TokenErrorResponse
13051305

1306-
from fastmcp.server.auth.oauth_proxy import TokenHandler
1306+
from fastmcp.server.auth.auth import TokenHandler
13071307

13081308
handler = TokenHandler(provider=Mock(), client_authenticator=Mock())
13091309

0 commit comments

Comments
 (0)