Skip to content

Commit a4bf947

Browse files
fix: Token endpoint response for invalid_client (#1481)
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
1 parent 3f6b059 commit a4bf947

File tree

2 files changed

+62
-7
lines changed

2 files changed

+62
-7
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ async def handle(self, request: Request):
9797
# Authentication failures should return 401
9898
return PydanticJSONResponse(
9999
content=TokenErrorResponse(
100-
error="unauthorized_client",
100+
error="invalid_client",
101101
error_description=e.message,
102102
),
103103
status_code=401,

tests/server/fastmcp/auth/test_auth_integration.py

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -339,9 +339,59 @@ async def test_token_validation_error(self, test_client: httpx.AsyncClient):
339339
},
340340
)
341341
error_response = response.json()
342-
assert error_response["error"] == "unauthorized_client"
342+
# Per RFC 6749 Section 5.2, authentication failures (missing client_id)
343+
# must return "invalid_client", not "unauthorized_client"
344+
assert error_response["error"] == "invalid_client"
343345
assert "error_description" in error_response # Contains error message
344346

347+
@pytest.mark.anyio
348+
async def test_token_invalid_client_secret_returns_invalid_client(
349+
self,
350+
test_client: httpx.AsyncClient,
351+
registered_client: dict[str, Any],
352+
pkce_challenge: dict[str, str],
353+
mock_oauth_provider: MockOAuthProvider,
354+
):
355+
"""Test token endpoint returns 'invalid_client' for wrong client_secret per RFC 6749.
356+
357+
RFC 6749 Section 5.2 defines:
358+
- invalid_client: Client authentication failed (wrong credentials, unknown client)
359+
- unauthorized_client: Authenticated client not authorized for grant type
360+
361+
When client_secret is wrong, this is an authentication failure, so the
362+
error code MUST be 'invalid_client'.
363+
"""
364+
# Create an auth code for the registered client
365+
auth_code = f"code_{int(time.time())}"
366+
mock_oauth_provider.auth_codes[auth_code] = AuthorizationCode(
367+
code=auth_code,
368+
client_id=registered_client["client_id"],
369+
code_challenge=pkce_challenge["code_challenge"],
370+
redirect_uri=AnyUrl("https://client.example.com/callback"),
371+
redirect_uri_provided_explicitly=True,
372+
scopes=["read", "write"],
373+
expires_at=time.time() + 600,
374+
)
375+
376+
# Try to exchange the auth code with a WRONG client_secret
377+
response = await test_client.post(
378+
"/token",
379+
data={
380+
"grant_type": "authorization_code",
381+
"client_id": registered_client["client_id"],
382+
"client_secret": "wrong_secret_that_does_not_match",
383+
"code": auth_code,
384+
"code_verifier": pkce_challenge["code_verifier"],
385+
"redirect_uri": "https://client.example.com/callback",
386+
},
387+
)
388+
389+
assert response.status_code == 401
390+
error_response = response.json()
391+
# RFC 6749 Section 5.2: authentication failures MUST return "invalid_client"
392+
assert error_response["error"] == "invalid_client"
393+
assert "Invalid client_secret" in error_response["error_description"]
394+
345395
@pytest.mark.anyio
346396
async def test_token_invalid_auth_code(
347397
self,
@@ -1070,7 +1120,8 @@ async def test_wrong_auth_method_without_valid_credentials_fails(
10701120
)
10711121
assert response.status_code == 401
10721122
error_response = response.json()
1073-
assert error_response["error"] == "unauthorized_client"
1123+
# RFC 6749: authentication failures return "invalid_client"
1124+
assert error_response["error"] == "invalid_client"
10741125
assert "Client secret is required" in error_response["error_description"]
10751126

10761127
@pytest.mark.anyio
@@ -1114,7 +1165,8 @@ async def test_basic_auth_without_header_fails(
11141165
)
11151166
assert response.status_code == 401
11161167
error_response = response.json()
1117-
assert error_response["error"] == "unauthorized_client"
1168+
# RFC 6749: authentication failures return "invalid_client"
1169+
assert error_response["error"] == "invalid_client"
11181170
assert "Missing or invalid Basic authentication" in error_response["error_description"]
11191171

11201172
@pytest.mark.anyio
@@ -1158,7 +1210,8 @@ async def test_basic_auth_invalid_base64_fails(
11581210
)
11591211
assert response.status_code == 401
11601212
error_response = response.json()
1161-
assert error_response["error"] == "unauthorized_client"
1213+
# RFC 6749: authentication failures return "invalid_client"
1214+
assert error_response["error"] == "invalid_client"
11621215
assert "Invalid Basic authentication header" in error_response["error_description"]
11631216

11641217
@pytest.mark.anyio
@@ -1205,7 +1258,8 @@ async def test_basic_auth_no_colon_fails(
12051258
)
12061259
assert response.status_code == 401
12071260
error_response = response.json()
1208-
assert error_response["error"] == "unauthorized_client"
1261+
# RFC 6749: authentication failures return "invalid_client"
1262+
assert error_response["error"] == "invalid_client"
12091263
assert "Invalid Basic authentication header" in error_response["error_description"]
12101264

12111265
@pytest.mark.anyio
@@ -1252,7 +1306,8 @@ async def test_basic_auth_client_id_mismatch_fails(
12521306
)
12531307
assert response.status_code == 401
12541308
error_response = response.json()
1255-
assert error_response["error"] == "unauthorized_client"
1309+
# RFC 6749: authentication failures return "invalid_client"
1310+
assert error_response["error"] == "invalid_client"
12561311
assert "Client ID mismatch" in error_response["error_description"]
12571312

12581313
@pytest.mark.anyio

0 commit comments

Comments
 (0)