Skip to content

Commit c994eb2

Browse files
committed
update token + revoke to use form data
1 parent aaf91dc commit c994eb2

File tree

11 files changed

+95
-28
lines changed

11 files changed

+95
-28
lines changed

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ This document contains critical information about working with this codebase. Fo
104104
- Add None checks
105105
- Narrow string types
106106
- Match existing patterns
107+
- Pytest:
108+
- If the tests aren't finding the anyio pytest mark, try adding PYTEST_DISABLE_PLUGIN_AUTOLOAD=""
109+
to the start of the pytest run command eg:
110+
`PYTEST_DISABLE_PLUGIN_AUTOLOAD="" uv run --frozen pytest`
107111

108112
3. Best Practices
109113
- Check git status before commits

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ dependencies = [
3030
"sse-starlette>=1.6.1",
3131
"pydantic-settings>=2.5.2",
3232
"uvicorn>=0.23.1",
33+
"python-multipart",
3334
]
3435

3536
[project.optional-dependencies]

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ class AuthorizationRequest(BaseModel):
3636
state: Optional[str] = Field(None, description="Optional state parameter")
3737
scope: Optional[str] = Field(
3838
None,
39-
description="Optional scope; if specified, should be " \
40-
"a space-separated list of scope strings",
39+
description="Optional scope; if specified, should be "
40+
"a space-separated list of scope strings",
4141
)
4242

4343
class Config:

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,8 @@ async def revocation_handler(request: Request) -> Response:
4545
Handler for the OAuth 2.0 Token Revocation endpoint.
4646
"""
4747
try:
48-
revocation_request = RevocationRequest.model_validate_json(
49-
await request.body()
50-
)
48+
form_data = await request.form()
49+
revocation_request = RevocationRequest.model_validate(dict(form_data))
5150
except ValidationError as e:
5251
raise InvalidRequestError(f"Invalid request body: {e}")
5352

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ def create_token_handler(
4949
) -> Callable:
5050
async def token_handler(request: Request):
5151
try:
52-
token_request = TokenRequest.model_validate_json(await request.body()).root
52+
form_data = await request.form()
53+
token_request = TokenRequest.model_validate(dict(form_data)).root
5354
except ValidationError as e:
5455
raise InvalidRequestError(f"Invalid request body: {e}")
5556
client_info = await client_authenticator(token_request)

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ class ClientAuthRequest(BaseModel):
2222
"""
2323
Model for client authentication request body.
2424
25-
Corresponds to ClientAuthenticatedRequestSchema in
25+
Corresponds to ClientAuthenticatedRequestSchema in
2626
src/server/auth/middleware/clientAuth.ts
2727
"""
2828

@@ -32,15 +32,16 @@ class ClientAuthRequest(BaseModel):
3232

3333
class ClientAuthenticator:
3434
"""
35-
ClientAuthenticator is a callable which validates requests from a client
35+
ClientAuthenticator is a callable which validates requests from a client
3636
application,
3737
used to verify /token and /revoke calls.
38-
If, during registration, the client requested to be issued a secret, the
39-
authenticator asserts that /token and /register calls must be authenticated with
38+
If, during registration, the client requested to be issued a secret, the
39+
authenticator asserts that /token and /register calls must be authenticated with
4040
that same token.
41-
NOTE: clients can opt for no authentication during registration, in which case this
41+
NOTE: clients can opt for no authentication during registration, in which case this
4242
logic is skipped.
4343
"""
44+
4445
def __init__(self, clients_store: OAuthRegisteredClientsStore):
4546
"""
4647
Initialize the dependency.

src/mcp/server/auth/provider.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,10 @@ async def create_authorization_code(
8181
self, client: OAuthClientInformationFull, params: AuthorizationParams
8282
) -> str:
8383
"""
84-
Generates and stores an authorization code as part of completing the /authorize
84+
Generates and stores an authorization code as part of completing the /authorize
8585
OAuth step.
8686
87-
Implementations SHOULD generate an authorization code with at least 160 bits of
87+
Implementations SHOULD generate an authorization code with at least 160 bits of
8888
entropy,
8989
and MUST generate an authorization code with at least 128 bits of entropy.
9090
See https://datatracker.ietf.org/doc/html/rfc6749#section-10.10.

src/mcp/server/sse.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ async def sse_writer():
132132
logger.debug("Yielding read and write streams")
133133
# TODO: hold on; shouldn't we be returning the EventSourceResponse?
134134
# I think this is why the tests hang
135-
# TODO: we probably shouldn't return response here, since it's a breaking
135+
# TODO: we probably shouldn't return response here, since it's a breaking
136136
# change
137137
# this is just to test
138138
yield (read_stream, write_stream, response)

src/mcp/shared/auth.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,19 +43,21 @@ class OAuthClientMetadata(BaseModel):
4343
"""
4444

4545
redirect_uris: List[AnyHttpUrl] = Field(..., min_length=1)
46-
# token_endpoint_auth_method: this implementation only supports none &
46+
# token_endpoint_auth_method: this implementation only supports none &
4747
# client_secret_basic;
4848
# ie: we do not support client_secret_post
49-
token_endpoint_auth_method: Literal["none", "client_secret_basic"] = \
49+
token_endpoint_auth_method: Literal["none", "client_secret_basic"] = (
5050
"client_secret_basic"
51+
)
5152
# grant_types: this implementation only supports authorization_code & refresh_token
52-
grant_types: List[Literal["authorization_code", "refresh_token"]] = \
53-
["authorization_code"]
53+
grant_types: List[Literal["authorization_code", "refresh_token"]] = [
54+
"authorization_code"
55+
]
5456
# this implementation only supports code; ie: it does not support implicit grants
5557
response_types: List[Literal["code"]] = ["code"]
5658
scope: Optional[str] = None
5759

58-
# these fields are currently unused, but we support & store them for potential
60+
# these fields are currently unused, but we support & store them for potential
5961
# future use
6062
client_name: Optional[str] = None
6163
client_uri: Optional[AnyHttpUrl] = None

tests/server/fastmcp/auth/test_auth_integration.py

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,55 @@ async def test_client_registration(
327327
# assert await mock_oauth_provider.clients_store.get_client(
328328
# client_info["client_id"]
329329
# ) is not None
330+
331+
@pytest.mark.anyio
332+
async def test_authorize_form_post(
333+
self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider
334+
):
335+
"""Test the authorization endpoint using POST with form-encoded data."""
336+
# Register a client
337+
client_metadata = {
338+
"redirect_uris": ["https://client.example.com/callback"],
339+
"client_name": "Test Client",
340+
"grant_types": ["authorization_code", "refresh_token"],
341+
}
342+
343+
response = await test_client.post(
344+
"/register",
345+
json=client_metadata,
346+
)
347+
assert response.status_code == 201
348+
client_info = response.json()
349+
350+
# Create a PKCE challenge
351+
code_verifier = "some_random_verifier_string"
352+
code_challenge = (
353+
base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest())
354+
.decode()
355+
.rstrip("=")
356+
)
357+
358+
# Use POST with form-encoded data for authorization
359+
response = await test_client.post(
360+
"/authorize",
361+
data={
362+
"response_type": "code",
363+
"client_id": client_info["client_id"],
364+
"redirect_uri": "https://client.example.com/callback",
365+
"code_challenge": code_challenge,
366+
"code_challenge_method": "S256",
367+
"state": "test_form_state",
368+
},
369+
)
370+
assert response.status_code == 302
371+
372+
# Extract the authorization code from the redirect URL
373+
redirect_url = response.headers["location"]
374+
parsed_url = urlparse(redirect_url)
375+
query_params = parse_qs(parsed_url.query)
376+
377+
assert "code" in query_params
378+
assert query_params["state"][0] == "test_form_state"
330379

331380
@pytest.mark.anyio
332381
async def test_authorization_flow(
@@ -337,7 +386,7 @@ async def test_authorization_flow(
337386
client_metadata = {
338387
"redirect_uris": ["https://client.example.com/callback"],
339388
"client_name": "Test Client",
340-
"grant_types": ["authorization_code", "refresh_token"]
389+
"grant_types": ["authorization_code", "refresh_token"],
341390
}
342391

343392
response = await test_client.post(
@@ -355,7 +404,7 @@ async def test_authorization_flow(
355404
.rstrip("=")
356405
)
357406

358-
# 3. Request authorization
407+
# 3. Request authorization using GET with query params
359408
response = await test_client.get(
360409
"/authorize",
361410
params={
@@ -381,7 +430,7 @@ async def test_authorization_flow(
381430
# 5. Exchange the authorization code for tokens
382431
response = await test_client.post(
383432
"/token",
384-
json={
433+
data={
385434
"grant_type": "authorization_code",
386435
"client_id": client_info["client_id"],
387436
"client_secret": client_info["client_secret"],
@@ -411,7 +460,7 @@ async def test_authorization_flow(
411460
# 7. Refresh the token
412461
response = await test_client.post(
413462
"/token",
414-
json={
463+
data={
415464
"grant_type": "refresh_token",
416465
"client_id": client_info["client_id"],
417466
"client_secret": client_info["client_secret"],
@@ -429,7 +478,7 @@ async def test_authorization_flow(
429478
# 8. Revoke the token
430479
response = await test_client.post(
431480
"/revoke",
432-
json={
481+
data={
433482
"client_id": client_info["client_id"],
434483
"client_secret": client_info["client_secret"],
435484
"token": new_token_response["access_token"],
@@ -505,10 +554,10 @@ def test_tool(x: int) -> str:
505554
.rstrip("=")
506555
)
507556

508-
# Request authorization
509-
response = await test_client.get(
557+
# Request authorization using POST with form-encoded data
558+
response = await test_client.post(
510559
"/authorize",
511-
params={
560+
data={
512561
"response_type": "code",
513562
"client_id": client_info["client_id"],
514563
"redirect_uri": "https://client.example.com/callback",
@@ -530,7 +579,7 @@ def test_tool(x: int) -> str:
530579
# Exchange the authorization code for tokens
531580
response = await test_client.post(
532581
"/token",
533-
json={
582+
data={
534583
"grant_type": "authorization_code",
535584
"client_id": client_info["client_id"],
536585
"client_secret": client_info["client_secret"],

0 commit comments

Comments
 (0)