Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
100 commits
Select commit Hold shift + click to select a range
682ff1e
wip
praboud-ant Mar 6, 2025
331d51e
Unwind changes
praboud-ant Mar 6, 2025
d283f56
wip
praboud-ant Mar 7, 2025
e96d280
Get tests passing
praboud-ant Mar 10, 2025
1e9dd4c
Clean up provider interface
praboud-ant Mar 10, 2025
d535089
Lint
praboud-ant Mar 10, 2025
031cadf
Clean up registration endpoint
praboud-ant Mar 10, 2025
765efb6
Lint
praboud-ant Mar 10, 2025
0637bc3
update token + revoke to use form data
praboud-ant Mar 10, 2025
b99633a
Adjust more things to fit spec
praboud-ant Mar 10, 2025
9ae1c21
Lint
praboud-ant Mar 10, 2025
50683b9
Remove dup
praboud-ant Mar 10, 2025
2c5f26a
Comment
praboud-ant Mar 10, 2025
e605994
Refactor back to authorize()
praboud-ant Mar 10, 2025
e7c5f87
Improve validation for /token
praboud-ant Mar 11, 2025
83c0c9f
Improve validation for registration
praboud-ant Mar 11, 2025
0c1aae9
Improve /authorize validation & add tests
praboud-ant Mar 11, 2025
038fb04
Hoist oauth token expiration check into bearer auth middleware
praboud-ant Mar 11, 2025
a4e17f3
Add tests for /revoke validation
praboud-ant Mar 11, 2025
5f11c60
Lint + typecheck
praboud-ant Mar 11, 2025
571913a
Clean up unused error classes
praboud-ant Mar 11, 2025
d43647f
Update to use Python 3.10 types
praboud-ant Mar 11, 2025
9d72c1e
Use classes for handlers
praboud-ant Mar 11, 2025
a5079af
Refactor
praboud-ant Mar 11, 2025
c4c2608
Simplify bearer auth logic
praboud-ant Mar 11, 2025
bc62d73
Avoid asyncio dependency in tests
praboud-ant Mar 11, 2025
3852179
Add comment
praboud-ant Mar 11, 2025
874838a
Lint
praboud-ant Mar 11, 2025
f788d79
Add json_response.py comment
praboud-ant Mar 11, 2025
152feb9
Format
praboud-ant Mar 11, 2025
f37ebc4
Move around the response models to be closer to the handlers
praboud-ant Mar 11, 2025
c2873fd
Get rid of silly TS comments
praboud-ant Mar 11, 2025
fe2c029
Remove ClientAuthRequest
praboud-ant Mar 11, 2025
3a13f5d
Reorganize AuthInfo
praboud-ant Mar 11, 2025
37c5fc4
Refactor client metadata endpoint
praboud-ant Mar 11, 2025
792d302
Make metadata more spec compliant
praboud-ant Mar 12, 2025
6c48b11
Use python 3.10 types everywhere
praboud-ant Mar 12, 2025
a437566
Add back authorization to the /revoke endpoint, simplify revoke
praboud-ant Mar 12, 2025
9fee929
Move around validation logic
praboud-ant Mar 12, 2025
d79be8f
Fixups while integrating new auth capabilities
praboud-ant Mar 19, 2025
8d637b4
Pull all auth settings out into a separate config
praboud-ant Mar 19, 2025
8c86bce
Move router file to be routes
praboud-ant Mar 19, 2025
31618c1
Add auth context middleware
praboud-ant Mar 19, 2025
5ebbc19
Validate scopes + provide default
praboud-ant Mar 19, 2025
50673c6
Validate grant_types on registration
praboud-ant Mar 19, 2025
02d76f3
auth: client implementation
dsp-ant Mar 12, 2025
88edddc
update lock
dsp-ant Mar 12, 2025
d774be7
fix
dsp-ant Mar 12, 2025
a09e958
foo
dsp-ant Mar 14, 2025
4e73552
Format
praboud-ant Mar 19, 2025
56f694e
Move StreamingASGITransport into the library code, so MCP integration…
praboud-ant Mar 19, 2025
60da682
Improved error handling, generic types for provider
praboud-ant Mar 21, 2025
374a0b4
Rename AuthInfo to AccessToken
praboud-ant Mar 21, 2025
fb5a568
Rename
praboud-ant Mar 22, 2025
76ddc65
Add docs
praboud-ant Mar 22, 2025
e42dbf5
Merge remote-tracking branch 'origin/main' into praboud/auth
praboud-ant Mar 22, 2025
10e00e7
Typecheck
praboud-ant Mar 22, 2025
87571d8
Return 401 on missing auth, not 403
praboud-ant Mar 25, 2025
c6f991b
Convert AuthContextMiddleware to plain ASGI middleware & add tests
praboud-ant Mar 25, 2025
482149e
Fix redirect_uri handling
praboud-ant Mar 25, 2025
5230180
Remove client for now
praboud-ant Mar 25, 2025
8e15abc
Add test for auth context middleware
praboud-ant Mar 25, 2025
0a1a408
Add CORS support
praboud-ant Mar 25, 2025
3069aa3
Comment
praboud-ant Mar 27, 2025
16f0688
Merge remote-tracking branch 'origin/main' into praboud/auth
praboud-ant Mar 27, 2025
5ecc7f0
Remove client tests
praboud-ant Mar 27, 2025
8c251c9
Add ignores
praboud-ant Mar 27, 2025
f46dcb1
Merge remote-tracking branch 'origin/main' into praboud/auth
praboud-ant Apr 18, 2025
d3725cf
Review feedback
praboud-ant Apr 18, 2025
07f4e3a
Fix stream resource leaks and upgrade Starlette
bhosmer-ant Apr 13, 2025
1237148
Lint
praboud-ant Apr 18, 2025
1ad1842
Review comments
praboud-ant Apr 18, 2025
fa068dd
Rename OAuthServerProvider to OAuthAuthorizationServerProvider
praboud-ant Apr 18, 2025
9b5709a
Merge branch 'main' into praboud/auth
ihrpr Apr 30, 2025
67d568b
revert starlette upgrade
ihrpr Apr 30, 2025
16a7efa
add python-multipart - was missing
ihrpr Apr 30, 2025
91c09a4
ruff
ihrpr Apr 30, 2025
0582bf5
try fixing test
ihrpr Apr 30, 2025
8194bce
increse timeout
ihrpr Apr 30, 2025
2c63020
fix test
ihrpr May 1, 2025
2ea68f2
test
ihrpr May 1, 2025
b0fe041
fix test
ihrpr May 1, 2025
ba366e3
test
ihrpr May 1, 2025
e1a9fec
test
ihrpr May 1, 2025
af4221f
skip test
ihrpr May 1, 2025
f2840fe
Test auth (#609)
ihrpr May 1, 2025
cda4401
remove pyright upgrade and ruff format
ihrpr May 1, 2025
f2cc6ee
uv lock
ihrpr May 1, 2025
9c78e5e
add an example
ihrpr May 1, 2025
59e6c64
comments
ihrpr May 1, 2025
cb7f0c4
comment
ihrpr May 1, 2025
8ddf7f7
ruff
ihrpr May 1, 2025
2c85c66
switch to localhost
pcarleton May 2, 2025
ed6dad2
add scopes to oauth metadata
pcarleton May 2, 2025
e9cee55
separate mcp scope from github scope
pcarleton May 2, 2025
0eef578
ruff
pcarleton May 2, 2025
01f3f05
Merge branch 'main' into ihrpr/auth-example
pcarleton May 2, 2025
ebb9151
Merge branch 'main' into ihrpr/auth-example
ihrpr May 6, 2025
9cfbb1c
Merge branch 'main' into ihrpr/auth-example
ihrpr May 7, 2025
6bfa708
Merge branch 'main' into ihrpr/auth-example
ihrpr May 7, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
update lock
  • Loading branch information
dsp-ant authored and praboud-ant committed Mar 19, 2025
commit 88edddcd0a776966cc87d1673b2a8d64b27c4af5
2 changes: 0 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ dependencies = [
"sse-starlette>=1.6.1",
"pydantic-settings>=2.5.2",
"uvicorn>=0.23.1",
"python-multipart",
]

[project.optional-dependencies]
Expand All @@ -48,7 +47,6 @@ dev-dependencies = [
"pytest>=8.3.4",
"ruff>=0.8.5",
"trio>=0.26.2",
"pytest-flakefinder>=1.1.0",
"pytest-xdist>=3.6.1",
]

Expand Down
277 changes: 240 additions & 37 deletions src/mcp/client/auth/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
authorization specification.
"""

import base64
import hashlib
import json
import logging
from datetime import datetime, timedelta
from typing import Any, Protocol
from urllib.parse import urlparse
from urllib.parse import urlencode, urlparse

import httpx
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field
Expand Down Expand Up @@ -373,7 +375,49 @@ class OAuthClientProvider(Protocol):
@property
def client_metadata(self) -> ClientMetadata: ...

def save_client_information(self, metadata: DynamicClientRegistration) -> None: ...
@property
def redirect_url(self) -> AnyHttpUrl: ...

async def open_user_agent(self, url: AnyHttpUrl) -> None:
"""
Opens the user agent to the given URL.
"""
...

async def client_registration(
self, endpoint: AnyHttpUrl
) -> DynamicClientRegistration | None:
"""
Loads the client registration for the given endpoint.
"""
...

async def store_client_registration(
self, endpoint: AnyHttpUrl, metadata: DynamicClientRegistration
) -> None:
"""
Stores the client registration to be retreived for the next session
"""
...

def code_verifier(self) -> str:
"""
Loads the PKCE code verifier for the current session.
See https://www.rfc-editor.org/rfc/rfc7636.html#section-4.1
"""
...

async def token(self) -> AccessToken | None:
"""
Loads the token for the current session.
"""
...

async def store_token(self, token: AccessToken) -> None:
"""
Stores the token to be retreived for the next session
"""
...


class NotFoundError(Exception):
Expand All @@ -388,29 +432,64 @@ class RegistrationFailedError(Exception):
pass


class GrantNotSupported(Exception):
"""Exception raised when a grant type is not supported."""

pass


class OAuthClient:
WELL_KNOWN = "/.well-known/oauth-authorization-server"

def __init__(self, server_url: AnyHttpUrl, provider: OAuthClientProvider):
GRANT_TYPE: str = "authorization_code"

def __init__(
self,
server_url: AnyHttpUrl,
provider: OAuthClientProvider,
scope: str | None = None,
):
self.server_url = server_url
self.http_client = httpx.AsyncClient()
self.provider = provider
self._registration: DynamicClientRegistration | None = None
self.scope = scope

async def auth(self):
metadata = await self.discover_auth_metadata() or self._default_metadata()
@property
def discovery_url(self) -> AnyHttpUrl:
base_url = str(self.server_url).rstrip("/")
parsed_url = urlparse(base_url)
# HTTPS is required by RFC 8414
discovery_url = f"https://{parsed_url.netloc}{self.WELL_KNOWN}"
return AnyHttpUrl(discovery_url)

async def _obtain_client(
self, metadata: ServerMetadataDiscovery
) -> DynamicClientRegistration:
"""
Obtain a client by either reading it from the OAuthProvider or registering it.
"""
if metadata.registration_endpoint is None:
raise NotFoundError("Registration endpoint not found")
self._registration = await self.dynamic_client_registration(
self.provider.client_metadata, metadata.registration_endpoint
)
if self._registration is None:
raise RegistrationFailedError(
f"Registration at {metadata.registration_endpoint} failed"

if registration := await self.provider.client_registration(metadata.issuer):
return registration
else:
registration = await self.dynamic_client_registration(
self.provider.client_metadata, metadata.registration_endpoint
)
self.provider.save_client_information(self._registration)
if registration is None:
raise RegistrationFailedError(
f"Registration at {metadata.registration_endpoint} failed"
)

def _default_metadata(self) -> ServerMetadataDiscovery:
await self.provider.store_client_registration(metadata.issuer, registration)
return registration

def default_metadata(self) -> ServerMetadataDiscovery:
"""
Returns default endpoints as specified in
https://spec.modelcontextprotocol.io/specification/draft/basic/authorization/
for the server.
"""
base_url = AnyHttpUrl(str(self.server_url).rstrip("/"))
return ServerMetadataDiscovery(
issuer=base_url,
Expand All @@ -423,10 +502,11 @@ def _default_metadata(self) -> ServerMetadataDiscovery:
)

async def discover_auth_metadata(self) -> ServerMetadataDiscovery | None:
discovery_url = self._build_discovery_url()

"""
Use RFC 8414 to discover the authorization server metadata.
"""
try:
response = await self.http_client.get(str(discovery_url))
response = await self.http_client.get(str(self.discovery_url))
if response.status_code == 404:
return None
response.raise_for_status()
Expand All @@ -439,31 +519,12 @@ async def discover_auth_metadata(self) -> ServerMetadataDiscovery | None:
logger.error(f"Error during auth metadata discovery: {e}")
raise

def _build_discovery_url(self) -> AnyHttpUrl:
base_url = str(self.server_url).rstrip("/")
parsed_url = urlparse(base_url)
# HTTPS is required by RFC 8414
discovery_url = f"https://{parsed_url.netloc}{self.WELL_KNOWN}"
return AnyHttpUrl(discovery_url)

async def dynamic_client_registration(
self, client_metadata: ClientMetadata, registration_endpoint: AnyHttpUrl
) -> DynamicClientRegistration | None:
"""
Register a client dynamically with an OAuth 2.0 authorization server
following RFC 7591.

Args:
client_metadata: Typed client registration metadata
registration_endpoint: Where to register clients.
If None, will use discovery

Returns:
DynamicClientRegistrationResponse if successful, None otherwise

Raises:
httpx.HTTPStatusError: If the server returns an error status code
Exception: For other errors during registration
"""
headers = {"Content-Type": "application/json", "Accept": "application/json"}

Expand Down Expand Up @@ -493,3 +554,145 @@ async def dynamic_client_registration(
logger.error(f"Unexpected error during registration: {e}")

return None

async def exchange_authorization(
self,
metadata: ServerMetadataDiscovery,
registration: DynamicClientRegistration,
code_verifier: str,
authorization_code: str,
) -> AccessToken:
"""Exchange an authorization code for an access token using OAuth 2.1 with PKCE.

Args:
registration: The client registration information
code_verifier: The PKCE code verifier used to generate the code challenge
authorization_code: The authorization code received from the authorization
server

Returns:
AccessToken: The resulting access token

Raises:
GrantNotSupported: If the grant type is not supported
httpx.HTTPStatusError: If the token endpoint request fails
"""
if self.GRANT_TYPE not in (registration.grant_types or []):
raise GrantNotSupported(f"Grant type {self.GRANT_TYPE} not supported")

code_verifier = self.provider.code_verifier()
# Get token endpoint from server metadata or use default
token_endpoint = str(metadata.token_endpoint)

# Prepare token request parameters
data = {
"grant_type": self.GRANT_TYPE,
"code": authorization_code,
"redirect_uri": str(self.provider.redirect_url),
"client_id": registration.client_id,
"code_verifier": code_verifier,
}

# Add client secret if available (optional in OAuth 2.1)
if registration.client_secret:
data["client_secret"] = registration.client_secret

headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
}

try:
response = await self.http_client.post(
token_endpoint, data=data, headers=headers
)
response.raise_for_status()
token_data = response.json()

# Create and return the token
return AccessToken(**token_data)

except httpx.HTTPStatusError as e:
logger.error(f"HTTP error during token exchange: {e.response.status_code}")
if e.response.content:
try:
error_data = json.loads(e.response.content)
logger.error(f"Error details: {error_data}")
except json.JSONDecodeError:
logger.error(f"Error content: {e.response.content}")
raise
except Exception as e:
logger.error(f"Unexpected error during token exchange: {e}")
raise

async def auth(self, authorization_code: str, code_verifier: str) -> AccessToken:
"""
Complete the OAuth 2.1 authorization flow by exchanging authorization code
for tokens.

Args:
authorization_code: The authorization code received from the authorization
server
code_verifier: The PKCE code verifier used to generate the code challenge

Returns:
AccessToken: The resulting access token
"""
metadata = await self.discover_auth_metadata() or self.default_metadata()
registration = await self._obtain_client(metadata)

code_verifier = self.provider.code_verifier()

authorization_url = self.get_authorization_url(
metadata.authorization_endpoint,
self.provider.redirect_url,
registration.client_id,
code_verifier,
self.scope,
)

await self.provider.open_user_agent(AnyHttpUrl(authorization_url))

return await self.exchange_authorization(
metadata, registration, code_verifier, authorization_code
)

def get_authorization_url(
self,
authorization_endpoint: AnyHttpUrl,
redirect_uri: AnyHttpUrl,
client_id: str,
code_verifier: str,
scope: str | None = None,
) -> AnyHttpUrl:
"""Generate an OAuth 2.1 authorization URL for the user agent.

This method generates a URL that the user agent (browser) should visit to
authenticate the user and authorize the application. It includes PKCE
(Proof Key for Code Exchange) for enhanced security as required by OAuth 2.1.
"""
# Create a custom verifier for this authorization request
code_verifier = self.provider.code_verifier()

# Generate code challenge from verifier using SHA-256
code_challenge = (
base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest())
.decode()
.rstrip("=")
)

# Build authorization URL with necessary parameters
params = {
"response_type": "code",
"client_id": client_id,
"redirect_uri": str(redirect_uri),
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}

# Add scope if provided or use the one from registration
if scope:
params["scope"] = scope

# Construct the full authorization URL
return AnyHttpUrl(f"{authorization_endpoint}?{urlencode(params)}")
14 changes: 0 additions & 14 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.