-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Add support for serverside oauth #255
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+4,120
−22
Merged
Changes from all commits
Commits
Show all changes
89 commits
Select commit
Hold shift + click to select a range
682ff1e
wip
praboud-ant 331d51e
Unwind changes
praboud-ant d283f56
wip
praboud-ant e96d280
Get tests passing
praboud-ant 1e9dd4c
Clean up provider interface
praboud-ant d535089
Lint
praboud-ant 031cadf
Clean up registration endpoint
praboud-ant 765efb6
Lint
praboud-ant 0637bc3
update token + revoke to use form data
praboud-ant b99633a
Adjust more things to fit spec
praboud-ant 9ae1c21
Lint
praboud-ant 50683b9
Remove dup
praboud-ant 2c5f26a
Comment
praboud-ant e605994
Refactor back to authorize()
praboud-ant e7c5f87
Improve validation for /token
praboud-ant 83c0c9f
Improve validation for registration
praboud-ant 0c1aae9
Improve /authorize validation & add tests
praboud-ant 038fb04
Hoist oauth token expiration check into bearer auth middleware
praboud-ant a4e17f3
Add tests for /revoke validation
praboud-ant 5f11c60
Lint + typecheck
praboud-ant 571913a
Clean up unused error classes
praboud-ant d43647f
Update to use Python 3.10 types
praboud-ant 9d72c1e
Use classes for handlers
praboud-ant a5079af
Refactor
praboud-ant c4c2608
Simplify bearer auth logic
praboud-ant bc62d73
Avoid asyncio dependency in tests
praboud-ant 3852179
Add comment
praboud-ant 874838a
Lint
praboud-ant f788d79
Add json_response.py comment
praboud-ant 152feb9
Format
praboud-ant f37ebc4
Move around the response models to be closer to the handlers
praboud-ant c2873fd
Get rid of silly TS comments
praboud-ant fe2c029
Remove ClientAuthRequest
praboud-ant 3a13f5d
Reorganize AuthInfo
praboud-ant 37c5fc4
Refactor client metadata endpoint
praboud-ant 792d302
Make metadata more spec compliant
praboud-ant 6c48b11
Use python 3.10 types everywhere
praboud-ant a437566
Add back authorization to the /revoke endpoint, simplify revoke
praboud-ant 9fee929
Move around validation logic
praboud-ant d79be8f
Fixups while integrating new auth capabilities
praboud-ant 8d637b4
Pull all auth settings out into a separate config
praboud-ant 8c86bce
Move router file to be routes
praboud-ant 31618c1
Add auth context middleware
praboud-ant 5ebbc19
Validate scopes + provide default
praboud-ant 50673c6
Validate grant_types on registration
praboud-ant 02d76f3
auth: client implementation
dsp-ant 88edddc
update lock
dsp-ant d774be7
fix
dsp-ant a09e958
foo
dsp-ant 4e73552
Format
praboud-ant 56f694e
Move StreamingASGITransport into the library code, so MCP integration…
praboud-ant 60da682
Improved error handling, generic types for provider
praboud-ant 374a0b4
Rename AuthInfo to AccessToken
praboud-ant fb5a568
Rename
praboud-ant 76ddc65
Add docs
praboud-ant e42dbf5
Merge remote-tracking branch 'origin/main' into praboud/auth
praboud-ant 10e00e7
Typecheck
praboud-ant 87571d8
Return 401 on missing auth, not 403
praboud-ant c6f991b
Convert AuthContextMiddleware to plain ASGI middleware & add tests
praboud-ant 482149e
Fix redirect_uri handling
praboud-ant 5230180
Remove client for now
praboud-ant 8e15abc
Add test for auth context middleware
praboud-ant 0a1a408
Add CORS support
praboud-ant 3069aa3
Comment
praboud-ant 16f0688
Merge remote-tracking branch 'origin/main' into praboud/auth
praboud-ant 5ecc7f0
Remove client tests
praboud-ant 8c251c9
Add ignores
praboud-ant f46dcb1
Merge remote-tracking branch 'origin/main' into praboud/auth
praboud-ant d3725cf
Review feedback
praboud-ant 07f4e3a
Fix stream resource leaks and upgrade Starlette
bhosmer-ant 1237148
Lint
praboud-ant 1ad1842
Review comments
praboud-ant fa068dd
Rename OAuthServerProvider to OAuthAuthorizationServerProvider
praboud-ant 9b5709a
Merge branch 'main' into praboud/auth
ihrpr 67d568b
revert starlette upgrade
ihrpr 16a7efa
add python-multipart - was missing
ihrpr 91c09a4
ruff
ihrpr 0582bf5
try fixing test
ihrpr 8194bce
increse timeout
ihrpr 2c63020
fix test
ihrpr 2ea68f2
test
ihrpr b0fe041
fix test
ihrpr ba366e3
test
ihrpr e1a9fec
test
ihrpr af4221f
skip test
ihrpr f2840fe
Test auth (#609)
ihrpr cda4401
remove pyright upgrade and ruff format
ihrpr f2cc6ee
uv lock
ihrpr 23ef519
Merge branch 'main' into praboud/auth
ihrpr File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -166,4 +166,5 @@ cython_debug/ | |
|
||
# vscode | ||
.vscode/ | ||
.windsurfrules | ||
**/CLAUDE.local.md |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
""" | ||
MCP OAuth server authorization components. | ||
""" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
from pydantic import ValidationError | ||
|
||
|
||
def stringify_pydantic_error(validation_error: ValidationError) -> str: | ||
return "\n".join( | ||
f"{'.'.join(str(loc) for loc in e['loc'])}: {e['msg']}" | ||
for e in validation_error.errors() | ||
) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
""" | ||
Request handlers for MCP authorization endpoints. | ||
""" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,244 @@ | ||
import logging | ||
from dataclasses import dataclass | ||
from typing import Any, Literal | ||
|
||
from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, RootModel, ValidationError | ||
from starlette.datastructures import FormData, QueryParams | ||
from starlette.requests import Request | ||
from starlette.responses import RedirectResponse, Response | ||
|
||
from mcp.server.auth.errors import ( | ||
stringify_pydantic_error, | ||
) | ||
from mcp.server.auth.json_response import PydanticJSONResponse | ||
from mcp.server.auth.provider import ( | ||
AuthorizationErrorCode, | ||
AuthorizationParams, | ||
AuthorizeError, | ||
OAuthAuthorizationServerProvider, | ||
construct_redirect_uri, | ||
) | ||
from mcp.shared.auth import ( | ||
InvalidRedirectUriError, | ||
InvalidScopeError, | ||
) | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
class AuthorizationRequest(BaseModel): | ||
# See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1 | ||
client_id: str = Field(..., description="The client ID") | ||
redirect_uri: AnyHttpUrl | None = Field( | ||
None, description="URL to redirect to after authorization" | ||
) | ||
|
||
# see OAuthClientMetadata; we only support `code` | ||
response_type: Literal["code"] = Field( | ||
..., description="Must be 'code' for authorization code flow" | ||
) | ||
code_challenge: str = Field(..., description="PKCE code challenge") | ||
code_challenge_method: Literal["S256"] = Field( | ||
"S256", description="PKCE code challenge method, must be S256" | ||
) | ||
state: str | None = Field(None, description="Optional state parameter") | ||
scope: str | None = Field( | ||
None, | ||
description="Optional scope; if specified, should be " | ||
"a space-separated list of scope strings", | ||
) | ||
|
||
|
||
class AuthorizationErrorResponse(BaseModel): | ||
error: AuthorizationErrorCode | ||
error_description: str | None | ||
error_uri: AnyUrl | None = None | ||
# must be set if provided in the request | ||
state: str | None = None | ||
|
||
|
||
def best_effort_extract_string( | ||
key: str, params: None | FormData | QueryParams | ||
) -> str | None: | ||
if params is None: | ||
return None | ||
value = params.get(key) | ||
if isinstance(value, str): | ||
return value | ||
return None | ||
|
||
|
||
class AnyHttpUrlModel(RootModel[AnyHttpUrl]): | ||
root: AnyHttpUrl | ||
|
||
|
||
@dataclass | ||
class AuthorizationHandler: | ||
provider: OAuthAuthorizationServerProvider[Any, Any, Any] | ||
|
||
async def handle(self, request: Request) -> Response: | ||
# implements authorization requests for grant_type=code; | ||
# see https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1 | ||
|
||
state = None | ||
redirect_uri = None | ||
client = None | ||
params = None | ||
|
||
async def error_response( | ||
error: AuthorizationErrorCode, | ||
error_description: str | None, | ||
attempt_load_client: bool = True, | ||
): | ||
# Error responses take two different formats: | ||
# 1. The request has a valid client ID & redirect_uri: we issue a redirect | ||
# back to the redirect_uri with the error response fields as query | ||
# parameters. This allows the client to be notified of the error. | ||
# 2. Otherwise, we return an error response directly to the end user; | ||
# we choose to do so in JSON, but this is left undefined in the | ||
# specification. | ||
# See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1 | ||
# | ||
# This logic is a bit awkward to handle, because the error might be thrown | ||
# very early in request validation, before we've done the usual Pydantic | ||
# validation, loaded the client, etc. To handle this, error_response() | ||
# contains fallback logic which attempts to load the parameters directly | ||
# from the request. | ||
|
||
nonlocal client, redirect_uri, state | ||
if client is None and attempt_load_client: | ||
# make last-ditch attempt to load the client | ||
client_id = best_effort_extract_string("client_id", params) | ||
client = client_id and await self.provider.get_client(client_id) | ||
if redirect_uri is None and client: | ||
# make last-ditch effort to load the redirect uri | ||
try: | ||
if params is not None and "redirect_uri" not in params: | ||
raw_redirect_uri = None | ||
else: | ||
raw_redirect_uri = AnyHttpUrlModel.model_validate( | ||
best_effort_extract_string("redirect_uri", params) | ||
).root | ||
redirect_uri = client.validate_redirect_uri(raw_redirect_uri) | ||
except (ValidationError, InvalidRedirectUriError): | ||
# if the redirect URI is invalid, ignore it & just return the | ||
# initial error | ||
pass | ||
|
||
# the error response MUST contain the state specified by the client, if any | ||
if state is None: | ||
# make last-ditch effort to load state | ||
state = best_effort_extract_string("state", params) | ||
|
||
error_resp = AuthorizationErrorResponse( | ||
error=error, | ||
error_description=error_description, | ||
state=state, | ||
) | ||
|
||
if redirect_uri and client: | ||
return RedirectResponse( | ||
url=construct_redirect_uri( | ||
str(redirect_uri), **error_resp.model_dump(exclude_none=True) | ||
), | ||
status_code=302, | ||
headers={"Cache-Control": "no-store"}, | ||
) | ||
else: | ||
return PydanticJSONResponse( | ||
status_code=400, | ||
content=error_resp, | ||
headers={"Cache-Control": "no-store"}, | ||
) | ||
|
||
try: | ||
# Parse request parameters | ||
if request.method == "GET": | ||
# Convert query_params to dict for pydantic validation | ||
params = request.query_params | ||
else: | ||
# Parse form data for POST requests | ||
params = await request.form() | ||
|
||
# Save state if it exists, even before validation | ||
state = best_effort_extract_string("state", params) | ||
|
||
try: | ||
auth_request = AuthorizationRequest.model_validate(params) | ||
state = auth_request.state # Update with validated state | ||
except ValidationError as validation_error: | ||
error: AuthorizationErrorCode = "invalid_request" | ||
for e in validation_error.errors(): | ||
if e["loc"] == ("response_type",) and e["type"] == "literal_error": | ||
error = "unsupported_response_type" | ||
break | ||
return await error_response( | ||
error, stringify_pydantic_error(validation_error) | ||
) | ||
|
||
# Get client information | ||
client = await self.provider.get_client( | ||
auth_request.client_id, | ||
) | ||
if not client: | ||
# For client_id validation errors, return direct error (no redirect) | ||
return await error_response( | ||
error="invalid_request", | ||
error_description=f"Client ID '{auth_request.client_id}' not found", | ||
attempt_load_client=False, | ||
) | ||
|
||
# Validate redirect_uri against client's registered URIs | ||
try: | ||
redirect_uri = client.validate_redirect_uri(auth_request.redirect_uri) | ||
except InvalidRedirectUriError as validation_error: | ||
# For redirect_uri validation errors, return direct error (no redirect) | ||
return await error_response( | ||
error="invalid_request", | ||
error_description=validation_error.message, | ||
) | ||
|
||
# Validate scope - for scope errors, we can redirect | ||
try: | ||
scopes = client.validate_scope(auth_request.scope) | ||
except InvalidScopeError as validation_error: | ||
# For scope errors, redirect with error parameters | ||
return await error_response( | ||
error="invalid_scope", | ||
error_description=validation_error.message, | ||
) | ||
|
||
# Setup authorization parameters | ||
auth_params = AuthorizationParams( | ||
state=state, | ||
scopes=scopes, | ||
code_challenge=auth_request.code_challenge, | ||
redirect_uri=redirect_uri, | ||
redirect_uri_provided_explicitly=auth_request.redirect_uri is not None, | ||
) | ||
|
||
try: | ||
# Let the provider pick the next URI to redirect to | ||
return RedirectResponse( | ||
url=await self.provider.authorize( | ||
client, | ||
auth_params, | ||
), | ||
status_code=302, | ||
headers={"Cache-Control": "no-store"}, | ||
) | ||
except AuthorizeError as e: | ||
# Handle authorization errors as defined in RFC 6749 Section 4.1.2.1 | ||
return await error_response( | ||
error=e.error, | ||
error_description=e.error_description, | ||
) | ||
|
||
except Exception as validation_error: | ||
# Catch-all for unexpected errors | ||
logger.exception( | ||
"Unexpected error in authorization_handler", exc_info=validation_error | ||
) | ||
return await error_response( | ||
error="server_error", error_description="An unexpected error occurred" | ||
) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
from dataclasses import dataclass | ||
|
||
from starlette.requests import Request | ||
from starlette.responses import Response | ||
|
||
from mcp.server.auth.json_response import PydanticJSONResponse | ||
from mcp.shared.auth import OAuthMetadata | ||
|
||
|
||
@dataclass | ||
class MetadataHandler: | ||
metadata: OAuthMetadata | ||
|
||
async def handle(self, request: Request) -> Response: | ||
return PydanticJSONResponse( | ||
content=self.metadata, | ||
headers={"Cache-Control": "public, max-age=3600"}, # Cache for 1 hour | ||
) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[non blocking] wonder if its worth noting that this wont scale beyond one instance
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that's something we can add to the example