Skip to content

Commit d425b7c

Browse files
committed
fix(auth): accept client credentials from Basic auth header in token endpoint
When a client uses HTTP Basic authentication (RFC 6749 §2.3.1), its client_id and client_secret arrive in the Authorization header rather than the request body. ClientAuthenticator already validates Basic auth correctly for `client_secret_basic` clients, but it failed early with "Missing client_id" when client_id was absent from form data. TokenHandler also required client_id in form data for TokenRequest validation, causing a second failure path. Changes: - ClientAuthenticator.authenticate_request: extract client_id from Basic auth header when not present in form body, before the missing-id check - TokenHandler.handle: populate client_id from the already-authenticated client_info when absent from form data, so TokenRequest validates cleanly Two new tests cover the authorization_code and refresh_token grant flows with client_id supplied only via Authorization header. Fixes #1315 Signed-off-by: afischh <afischh@gmail.com>
1 parent e8e6484 commit d425b7c

3 files changed

Lines changed: 105 additions & 3 deletions

File tree

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,13 @@ async def handle(self, request: Request):
9595
)
9696

9797
try:
98-
form_data = await request.form()
99-
# TODO(Marcelo): Can someone check if this `dict()` wrapper is necessary?
100-
token_request = token_request_adapter.validate_python(dict(form_data))
98+
form_data = dict(await request.form())
99+
# client_id may have been supplied via HTTP Basic auth header instead of the
100+
# request body (RFC 6749 §2.3.1). ClientAuthenticator already verified it,
101+
# so we can safely populate it from client_info when absent from form data.
102+
if "client_id" not in form_data:
103+
form_data["client_id"] = client_info.client_id
104+
token_request = token_request_adapter.validate_python(form_data)
101105
except ValidationError as validation_error: # pragma: no cover
102106
return self.response(
103107
TokenErrorResponse(

src/mcp/server/auth/middleware/client_auth.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,16 @@ async def authenticate_request(self, request: Request) -> OAuthClientInformation
5353
"""
5454
form_data = await request.form()
5555
client_id = form_data.get("client_id")
56+
if not client_id:
57+
# RFC 6749 §2.3.1: client credentials MAY be sent via HTTP Basic auth
58+
auth_header = request.headers.get("Authorization", "")
59+
if auth_header.startswith("Basic "):
60+
try:
61+
decoded = base64.b64decode(auth_header[6:]).decode("utf-8")
62+
if ":" in decoded:
63+
client_id = unquote(decoded.split(":", 1)[0])
64+
except (ValueError, UnicodeDecodeError, binascii.Error):
65+
pass
5666
if not client_id:
5767
raise AuthenticationError("Missing client_id")
5868

tests/server/mcpserver/auth/test_auth_integration.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1367,6 +1367,94 @@ async def test_none_auth_method_public_client(
13671367
assert "access_token" in token_response
13681368

13691369

1370+
@pytest.mark.anyio
1371+
async def test_basic_auth_without_client_id_in_body(
1372+
self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider, pkce_challenge: dict[str, str]
1373+
):
1374+
"""Test RFC 6749 §2.3.1: client_id supplied only via Basic auth header, not in body."""
1375+
client_metadata = {
1376+
"redirect_uris": ["https://client.example.com/callback"],
1377+
"client_name": "Basic Auth Only Header Client",
1378+
"token_endpoint_auth_method": "client_secret_basic",
1379+
"grant_types": ["authorization_code", "refresh_token"],
1380+
}
1381+
1382+
response = await test_client.post("/register", json=client_metadata)
1383+
assert response.status_code == 201
1384+
client_info = response.json()
1385+
1386+
auth_code = f"code_{int(time.time())}"
1387+
mock_oauth_provider.auth_codes[auth_code] = AuthorizationCode(
1388+
code=auth_code,
1389+
client_id=client_info["client_id"],
1390+
code_challenge=pkce_challenge["code_challenge"],
1391+
redirect_uri=AnyUrl("https://client.example.com/callback"),
1392+
redirect_uri_provided_explicitly=True,
1393+
scopes=["read", "write"],
1394+
expires_at=time.time() + 600,
1395+
)
1396+
1397+
credentials = f"{client_info['client_id']}:{client_info['client_secret']}"
1398+
encoded_credentials = base64.b64encode(credentials.encode()).decode()
1399+
1400+
# client_id intentionally omitted from body — only in Authorization header
1401+
response = await test_client.post(
1402+
"/token",
1403+
headers={"Authorization": f"Basic {encoded_credentials}"},
1404+
data={
1405+
"grant_type": "authorization_code",
1406+
"code": auth_code,
1407+
"code_verifier": pkce_challenge["code_verifier"],
1408+
"redirect_uri": "https://client.example.com/callback",
1409+
},
1410+
)
1411+
assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.text}"
1412+
token_response = response.json()
1413+
assert "access_token" in token_response
1414+
1415+
@pytest.mark.anyio
1416+
async def test_basic_auth_refresh_token_without_client_id_in_body(
1417+
self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider, pkce_challenge: dict[str, str]
1418+
):
1419+
"""Test RFC 6749 §2.3.1: refresh_token grant with client_id only in Basic auth header."""
1420+
client_metadata = {
1421+
"redirect_uris": ["https://client.example.com/callback"],
1422+
"client_name": "Basic Auth Refresh Client",
1423+
"token_endpoint_auth_method": "client_secret_basic",
1424+
"grant_types": ["authorization_code", "refresh_token"],
1425+
}
1426+
1427+
response = await test_client.post("/register", json=client_metadata)
1428+
assert response.status_code == 201
1429+
client_info = response.json()
1430+
1431+
access_token_str = f"access_{secrets.token_hex(16)}"
1432+
refresh_token_str = f"refresh_{int(time.time())}"
1433+
mock_oauth_provider.tokens[access_token_str] = AccessToken(
1434+
token=access_token_str,
1435+
client_id=client_info["client_id"],
1436+
scopes=["read"],
1437+
expires_at=int(time.time()) + 3600,
1438+
)
1439+
mock_oauth_provider.refresh_tokens[refresh_token_str] = access_token_str
1440+
1441+
credentials = f"{client_info['client_id']}:{client_info['client_secret']}"
1442+
encoded_credentials = base64.b64encode(credentials.encode()).decode()
1443+
1444+
# client_id intentionally omitted from body — only in Authorization header
1445+
response = await test_client.post(
1446+
"/token",
1447+
headers={"Authorization": f"Basic {encoded_credentials}"},
1448+
data={
1449+
"grant_type": "refresh_token",
1450+
"refresh_token": refresh_token_str,
1451+
},
1452+
)
1453+
assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.text}"
1454+
token_response = response.json()
1455+
assert "access_token" in token_response
1456+
1457+
13701458
class TestAuthorizeEndpointErrors:
13711459
"""Test error handling in the OAuth authorization endpoint."""
13721460

0 commit comments

Comments
 (0)