Skip to content

Commit 4193c6a

Browse files
doobidooHeinrich Kruppclaudegemini-code-assist[bot]greptile-apps[bot]
authored
fix: OAuth token exchange fails with 500 for public PKCE clients (#576) (#577)
* fix: OAuth token exchange fails with 500 for public PKCE clients (#576) Three fixes for OAuth flow failing when claude.ai connects via MCP: 1. Allow public clients (no client_secret) using PKCE code_verifier for identity proof per OAuth 2.1 §2.1 — previously authenticate_client() was called with empty string, always failing authentication. 2. Add /.well-known/oauth-protected-resource endpoint (RFC 9728) which was returning 404 — required by MCP spec for OAuth discovery. 3. Add exc_info=True to error logging in token and authorization endpoints so actual exceptions are visible in logs instead of generic "Token endpoint error" messages. Closes #576 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Update src/mcp_memory_service/web/oauth/authorization.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update src/mcp_memory_service/web/oauth/discovery.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix: address code review security findings - Enforce PKCE (code_verifier) for public clients — prevents zero-auth token exchange when no client_secret and no PKCE challenge (OAuth 2.1 §7.5.2) - Fix type annotation: final_client_secret is Optional[str], not str - Fix resource_documentation: remove broken _docs_endpoint_exists() call from committed suggestion, use FastAPI's default /docs endpoint URL Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Heinrich Krupp <hkr@Mac-mini-von-Heinrich.local> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
1 parent 8a9a0e5 commit 4193c6a

File tree

3 files changed

+74
-14
lines changed

3 files changed

+74
-14
lines changed

src/mcp_memory_service/web/oauth/authorization.py

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,7 @@ async def authorize_post(
295295
except HTTPException:
296296
raise
297297
except Exception:
298-
logger.error("Authorization error occurred")
298+
logger.error("Authorization error occurred", exc_info=True)
299299
error_params = {"error": "server_error", "error_description": "Internal server error"}
300300
if state:
301301
error_params["state"] = _sanitize_state(state)
@@ -306,7 +306,7 @@ async def authorize_post(
306306

307307
async def _handle_authorization_code_grant(
308308
final_client_id: str,
309-
final_client_secret: str,
309+
final_client_secret: Optional[str],
310310
code: Optional[str],
311311
redirect_uri: Optional[str],
312312
code_verifier: Optional[str] = None
@@ -330,15 +330,38 @@ async def _handle_authorization_code_grant(
330330
}
331331
)
332332

333-
# Authenticate client
334-
if not await get_oauth_storage().authenticate_client(final_client_id, final_client_secret or ""):
335-
raise HTTPException(
336-
status_code=status.HTTP_401_UNAUTHORIZED,
337-
detail={
338-
"error": "invalid_client",
339-
"error_description": "Client authentication failed"
340-
}
341-
)
333+
# Authenticate client — but allow public clients using PKCE (OAuth 2.1 §2.1)
334+
# Public clients (e.g. claude.ai) may not send a client_secret; they prove
335+
# identity via PKCE code_verifier instead.
336+
if final_client_secret:
337+
if not await get_oauth_storage().authenticate_client(final_client_id, final_client_secret):
338+
raise HTTPException(
339+
status_code=status.HTTP_401_UNAUTHORIZED,
340+
detail={
341+
"error": "invalid_client",
342+
"error_description": "Client authentication failed"
343+
}
344+
)
345+
else:
346+
# Public client — verify it exists and is actually a public client
347+
client = await get_oauth_storage().get_client(final_client_id)
348+
if not client or client.token_endpoint_auth_method != "none":
349+
raise HTTPException(
350+
status_code=status.HTTP_401_UNAUTHORIZED,
351+
detail={
352+
"error": "invalid_client",
353+
"error_description": "Client authentication failed"
354+
}
355+
)
356+
# PKCE is mandatory for public clients (OAuth 2.1 §7.5.2)
357+
if not code_verifier:
358+
raise HTTPException(
359+
status_code=status.HTTP_400_BAD_REQUEST,
360+
detail={
361+
"error": "invalid_request",
362+
"error_description": "code_verifier required for public clients"
363+
}
364+
)
342365

343366
# Get and consume authorization code
344367
code_data = await get_oauth_storage().get_authorization_code(code)
@@ -507,7 +530,7 @@ async def token(
507530
# Re-raise HTTP exceptions
508531
raise
509532
except Exception:
510-
logger.error("Token endpoint error")
533+
logger.error("Token endpoint error", exc_info=True)
511534
raise HTTPException(
512535
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
513536
detail={

src/mcp_memory_service/web/oauth/discovery.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,33 @@
2121
import logging
2222
from fastapi import APIRouter
2323
from ...config import OAUTH_ISSUER, get_jwt_algorithm
24-
from .models import OAuthServerMetadata
24+
from .models import OAuthServerMetadata, OAuthProtectedResourceMetadata
2525

2626
logger = logging.getLogger(__name__)
2727

2828
router = APIRouter()
2929

3030

3131

32+
@router.get("/.well-known/oauth-protected-resource")
33+
async def oauth_protected_resource_metadata() -> OAuthProtectedResourceMetadata:
34+
"""
35+
OAuth 2.0 Protected Resource Metadata endpoint (RFC 9728).
36+
37+
Returns metadata about the protected resource, including which authorization
38+
server(s) can issue tokens for it. Required by MCP spec for OAuth integration.
39+
"""
40+
logger.info("OAuth protected resource metadata requested")
41+
42+
return OAuthProtectedResourceMetadata(
43+
resource=OAUTH_ISSUER,
44+
authorization_servers=[OAUTH_ISSUER],
45+
scopes_supported=["read", "write", "admin"],
46+
bearer_methods_supported=["header"],
47+
resource_documentation=f"{OAUTH_ISSUER}/docs",
48+
)
49+
50+
3251
@router.get("/.well-known/oauth-authorization-server/mcp")
3352
async def oauth_authorization_server_metadata() -> OAuthServerMetadata:
3453
"""
@@ -49,7 +68,7 @@ async def oauth_authorization_server_metadata() -> OAuthServerMetadata:
4968
registration_endpoint=f"{OAUTH_ISSUER}/oauth/register",
5069
grant_types_supported=["authorization_code", "client_credentials"],
5170
response_types_supported=["code"],
52-
token_endpoint_auth_methods_supported=["client_secret_basic", "client_secret_post"],
71+
token_endpoint_auth_methods_supported=["client_secret_basic", "client_secret_post", "none"],
5372
scopes_supported=["read", "write", "admin"],
5473
id_token_signing_alg_values_supported=[algorithm],
5574
code_challenge_methods_supported=["S256"]

src/mcp_memory_service/web/oauth/models.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,24 @@ class OAuthServerMetadata(BaseModel):
5454
)
5555

5656

57+
class OAuthProtectedResourceMetadata(BaseModel):
58+
"""OAuth 2.0 Protected Resource Metadata (RFC 9728)."""
59+
60+
resource: str = Field(..., description="Protected resource identifier (its URL)")
61+
authorization_servers: List[str] = Field(
62+
..., description="Authorization server(s) that can authorize access"
63+
)
64+
scopes_supported: Optional[List[str]] = Field(
65+
default=None, description="Scopes supported by this resource"
66+
)
67+
bearer_methods_supported: Optional[List[str]] = Field(
68+
default=None, description="Methods for sending bearer tokens"
69+
)
70+
resource_documentation: Optional[str] = Field(
71+
default=None, description="URL of resource documentation"
72+
)
73+
74+
5775
class ClientRegistrationRequest(BaseModel):
5876
"""OAuth 2.1 Dynamic Client Registration Request (RFC 7591).
5977

0 commit comments

Comments
 (0)