Skip to content

Commit 5919686

Browse files
committed
fix(auth): return RFC 6749 compliant OAuth error codes for token and revoke (#3723)
1 parent a525a3d commit 5919686

4 files changed

Lines changed: 108 additions & 8 deletions

File tree

‎src/mcp/server/auth/handlers/revoke.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ class RevocationRequest(BaseModel):
2424

2525

2626
class RevocationErrorResponse(BaseModel):
27-
error: Literal["invalid_request", "unauthorized_client"]
27+
error: Literal["invalid_request", "invalid_client", "unauthorized_client"]
2828
error_description: str | None = None
2929

3030

@@ -37,13 +37,17 @@ async def handle(self, request: Request) -> Response:
3737
"""Handler for the OAuth 2.0 Token Revocation endpoint."""
3838
try:
3939
client = await self.client_authenticator.authenticate_request(request)
40-
except AuthenticationError as e: # pragma: no cover
40+
except AuthenticationError as e:
4141
return PydanticJSONResponse(
4242
status_code=401,
4343
content=RevocationErrorResponse(
44-
error="unauthorized_client",
44+
error="invalid_client",
4545
error_description=e.message,
4646
),
47+
headers={
48+
"Cache-Control": "no-store",
49+
"Pragma": "no-cache",
50+
},
4751
)
4852

4953
try:

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

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,10 +117,31 @@ async def handle(self, request: Request):
117117
},
118118
)
119119

120+
form_data = await request.form()
121+
form_dict = dict(form_data)
122+
123+
# RFC 6749 §5.2: If the authorization grant type is not supported by the
124+
# authorization server, return unsupported_grant_type.
125+
supported_grant_types: set[str] = {"authorization_code", "refresh_token"}
126+
if self.identity_assertion_enabled:
127+
supported_grant_types.add("urn:ietf:params:oauth:grant-type:jwt-bearer")
128+
129+
grant_type = form_dict.get("grant_type")
130+
if grant_type and grant_type not in supported_grant_types:
131+
if grant_type == "urn:ietf:params:oauth:grant-type:jwt-bearer":
132+
desc = "The JWT bearer grant is not supported by this authorization server"
133+
else:
134+
desc = f"Unsupported grant type: {grant_type}"
135+
return self.response(
136+
TokenErrorResponse(
137+
error="unsupported_grant_type",
138+
error_description=desc,
139+
)
140+
)
141+
120142
try:
121-
form_data = await request.form()
122143
# TODO(Marcelo): Can someone check if this `dict()` wrapper is necessary?
123-
token_request = token_request_adapter.validate_python(dict(form_data))
144+
token_request = token_request_adapter.validate_python(form_dict)
124145
except ValidationError as validation_error:
125146
return self.response(
126147
TokenErrorResponse(
@@ -130,10 +151,12 @@ async def handle(self, request: Request):
130151
)
131152

132153
if token_request.grant_type not in client_info.grant_types:
154+
# RFC 6749 §5.2: unauthorized_client: The authenticated client is not
155+
# authorized to use this authorization grant type.
133156
return self.response(
134157
TokenErrorResponse(
135-
error="unsupported_grant_type",
136-
error_description=(f"Unsupported grant type (supported grant types are {client_info.grant_types})"),
158+
error="unauthorized_client",
159+
error_description=(f"Client is not authorized to use grant type '{token_request.grant_type}'"),
137160
)
138161
)
139162

‎tests/server/auth/test_error_handling.py‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,79 @@ async def test_token_error_handling_refresh_token(
291291
assert data["error_description"] == "The requested scope is invalid"
292292

293293

294+
@pytest.mark.anyio
295+
async def test_token_unsupported_grant_type_returns_unsupported_grant_type(
296+
client: httpx2.AsyncClient,
297+
registered_client: dict[str, Any],
298+
):
299+
"""RFC 6749 §5.2: Server must return unsupported_grant_type when the grant type is not supported."""
300+
client_id = registered_client["client_id"]
301+
client_secret = registered_client["client_secret"]
302+
303+
response = await client.post(
304+
"/token",
305+
data={
306+
"grant_type": "client_credentials",
307+
"client_id": client_id,
308+
"client_secret": client_secret,
309+
},
310+
)
311+
assert response.status_code == 400
312+
data = response.json()
313+
assert data["error"] == "unsupported_grant_type"
314+
assert "client_credentials" in data["error_description"]
315+
316+
317+
@pytest.mark.anyio
318+
async def test_token_unauthorized_client_grant_type_returns_unauthorized_client(
319+
client: httpx2.AsyncClient,
320+
registered_client: dict[str, Any],
321+
oauth_provider: MockOAuthProvider,
322+
):
323+
"""RFC 6749 §5.2: Server must return unauthorized_client when the client is not authorized for grant type."""
324+
client_id = registered_client["client_id"]
325+
client_secret = registered_client["client_secret"]
326+
327+
# Modify the registered client to only allow 'authorization_code'
328+
stored_client = oauth_provider.clients[client_id]
329+
oauth_provider.clients[client_id] = stored_client.model_copy(update={"grant_types": ["authorization_code"]})
330+
331+
response = await client.post(
332+
"/token",
333+
data={
334+
"grant_type": "refresh_token",
335+
"refresh_token": "dummy_refresh_token",
336+
"client_id": client_id,
337+
"client_secret": client_secret,
338+
},
339+
)
340+
assert response.status_code == 400
341+
data = response.json()
342+
assert data["error"] == "unauthorized_client"
343+
344+
345+
@pytest.mark.anyio
346+
async def test_revoke_invalid_client_authentication_returns_401(
347+
client: httpx2.AsyncClient,
348+
registered_client: dict[str, Any],
349+
):
350+
"""RFC 7009 §2.2.1 & RFC 6749 §5.2: Revocation endpoint returns 401 invalid_client on auth failure."""
351+
client_id = registered_client["client_id"]
352+
353+
# Wrong client_secret
354+
response = await client.post(
355+
"/revoke",
356+
data={
357+
"token": "some_token",
358+
"client_id": client_id,
359+
"client_secret": "wrong_secret",
360+
},
361+
)
362+
assert response.status_code == 401
363+
data = response.json()
364+
assert data["error"] == "invalid_client"
365+
366+
294367
_FORM = "application/x-www-form-urlencoded"
295368

296369

‎tests/server/auth/test_identity_assertion.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ async def test_client_without_the_grant_registered_is_rejected(
309309
)
310310

311311
assert response.status_code == 400
312-
assert response.json()["error"] == "unsupported_grant_type"
312+
assert response.json()["error"] == "unauthorized_client"
313313
assert provider.last_params is None
314314

315315

0 commit comments

Comments
 (0)