|
36 | 36 | from key_value.aio.protocols import AsyncKeyValue |
37 | 37 | from key_value.aio.stores.disk import DiskStore |
38 | 38 | from key_value.aio.wrappers.encryption import FernetEncryptionWrapper |
39 | | -from mcp.server.auth.handlers.token import TokenErrorResponse, TokenSuccessResponse |
| 39 | +from mcp.server.auth.handlers.token import TokenErrorResponse |
40 | 40 | from mcp.server.auth.handlers.token import TokenHandler as _SDKTokenHandler |
41 | 41 | from mcp.server.auth.json_response import PydanticJSONResponse |
42 | 42 | from mcp.server.auth.middleware.client_auth import ClientAuthenticator |
@@ -522,50 +522,43 @@ def create_error_html( |
522 | 522 | class TokenHandler(_SDKTokenHandler): |
523 | 523 | """TokenHandler that returns OAuth 2.1 compliant error responses. |
524 | 524 |
|
525 | | - The MCP SDK always returns HTTP 400 for all client authentication issues. |
526 | | - However, OAuth 2.1 Section 5.3 and the MCP specification require that |
527 | | - invalid or expired tokens MUST receive a HTTP 401 response. |
| 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 | 528 |
|
529 | | - This handler extends the base MCP SDK TokenHandler to transform client |
530 | | - authentication failures into OAuth 2.1 compliant responses: |
531 | | - - Changes 'unauthorized_client' to 'invalid_client' error code |
532 | | - - Returns HTTP 401 status code instead of 400 for client auth failures |
| 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 | 533 |
|
534 | | - Per OAuth 2.1 Section 5.3: "The authorization server MAY return an HTTP 401 |
535 | | - (Unauthorized) status code to indicate which HTTP authentication schemes |
536 | | - are supported." |
537 | | -
|
538 | | - Per MCP spec: "Invalid or expired tokens MUST receive a HTTP 401 response." |
| 534 | + This handler transforms 401 responses with `unauthorized_client` to use |
| 535 | + `invalid_client` instead, making the error semantics correct per OAuth spec. |
539 | 536 | """ |
540 | 537 |
|
541 | | - def response(self, obj: TokenSuccessResponse | TokenErrorResponse): |
542 | | - """Override response method to provide OAuth 2.1 compliant error handling.""" |
543 | | - # Check if this is a client authentication failure (not just unauthorized for grant type) |
544 | | - # unauthorized_client can mean two things: |
545 | | - # 1. Client authentication failed (client_id not found or wrong credentials) -> invalid_client 401 |
546 | | - # 2. Client not authorized for this grant type -> unauthorized_client 400 (correct per spec) |
547 | | - if ( |
548 | | - isinstance(obj, TokenErrorResponse) |
549 | | - and obj.error == "unauthorized_client" |
550 | | - and obj.error_description |
551 | | - and "Invalid client_id" in obj.error_description |
552 | | - ): |
553 | | - # Transform client auth failure to OAuth 2.1 compliant response |
554 | | - return PydanticJSONResponse( |
555 | | - content=TokenErrorResponse( |
556 | | - error="invalid_client", |
557 | | - error_description=obj.error_description, |
558 | | - error_uri=obj.error_uri, |
559 | | - ), |
560 | | - status_code=401, |
561 | | - headers={ |
562 | | - "Cache-Control": "no-store", |
563 | | - "Pragma": "no-cache", |
564 | | - }, |
565 | | - ) |
| 538 | + async def handle(self, request: Any): |
| 539 | + """Wrap SDK handle() and transform auth error responses.""" |
| 540 | + response = await super().handle(request) |
566 | 541 |
|
567 | | - # Otherwise use default behavior from parent class |
568 | | - return super().response(obj) |
| 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 |
569 | 562 |
|
570 | 563 |
|
571 | 564 | class OAuthProxy(OAuthProvider): |
@@ -993,9 +986,13 @@ async def register_client(self, client_info: OAuthClientInformationFull) -> None |
993 | 986 | # Create a ProxyDCRClient with configured redirect URI validation |
994 | 987 | if client_info.client_id is None: |
995 | 988 | raise ValueError("client_id is required for client registration") |
| 989 | + # We use token_endpoint_auth_method="none" because the proxy handles |
| 990 | + # all upstream authentication. The client_secret must also be None |
| 991 | + # because the SDK requires secrets to be provided if they're set, |
| 992 | + # regardless of auth method. |
996 | 993 | proxy_client: ProxyDCRClient = ProxyDCRClient( |
997 | 994 | client_id=client_info.client_id, |
998 | | - client_secret=client_info.client_secret, |
| 995 | + client_secret=None, |
999 | 996 | redirect_uris=client_info.redirect_uris or [AnyUrl("http://localhost")], |
1000 | 997 | grant_types=client_info.grant_types |
1001 | 998 | or ["authorization_code", "refresh_token"], |
|
0 commit comments