MCP server separation into Authorization Server (AS) and Resource Server (RS) roles per spec PR #338 (#982)
Co-authored-by: Paul Carleton <paulc@anthropic.com>
This commit is contained in:
parent
d0443a1832
commit
17f9c00c53
9 changed files with 1851 additions and 1603 deletions
|
|
@ -11,11 +11,13 @@ import secrets
|
|||
import string
|
||||
import time
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Protocol
|
||||
from urllib.parse import urlencode, urljoin
|
||||
from urllib.parse import urlencode, urljoin, urlparse
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from mcp.client.streamable_http import MCP_PROTOCOL_VERSION
|
||||
from mcp.shared.auth import (
|
||||
|
|
@ -23,12 +25,40 @@ from mcp.shared.auth import (
|
|||
OAuthClientMetadata,
|
||||
OAuthMetadata,
|
||||
OAuthToken,
|
||||
ProtectedResourceMetadata,
|
||||
)
|
||||
from mcp.types import LATEST_PROTOCOL_VERSION
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OAuthFlowError(Exception):
|
||||
"""Base exception for OAuth flow errors."""
|
||||
|
||||
|
||||
class OAuthTokenError(OAuthFlowError):
|
||||
"""Raised when token operations fail."""
|
||||
|
||||
|
||||
class OAuthRegistrationError(OAuthFlowError):
|
||||
"""Raised when client registration fails."""
|
||||
|
||||
|
||||
class PKCEParameters(BaseModel):
|
||||
"""PKCE (Proof Key for Code Exchange) parameters."""
|
||||
|
||||
code_verifier: str = Field(..., min_length=43, max_length=128)
|
||||
code_challenge: str = Field(..., min_length=43, max_length=128)
|
||||
|
||||
@classmethod
|
||||
def generate(cls) -> "PKCEParameters":
|
||||
"""Generate new PKCE parameters."""
|
||||
code_verifier = "".join(secrets.choice(string.ascii_letters + string.digits + "-._~") for _ in range(128))
|
||||
digest = hashlib.sha256(code_verifier.encode()).digest()
|
||||
code_challenge = base64.urlsafe_b64encode(digest).decode().rstrip("=")
|
||||
return cls(code_verifier=code_verifier, code_challenge=code_challenge)
|
||||
|
||||
|
||||
class TokenStorage(Protocol):
|
||||
"""Protocol for token storage implementations."""
|
||||
|
||||
|
|
@ -49,12 +79,70 @@ class TokenStorage(Protocol):
|
|||
...
|
||||
|
||||
|
||||
@dataclass
|
||||
class OAuthContext:
|
||||
"""OAuth flow context."""
|
||||
|
||||
server_url: str
|
||||
client_metadata: OAuthClientMetadata
|
||||
storage: TokenStorage
|
||||
redirect_handler: Callable[[str], Awaitable[None]]
|
||||
callback_handler: Callable[[], Awaitable[tuple[str, str | None]]]
|
||||
timeout: float = 300.0
|
||||
|
||||
# Discovered metadata
|
||||
protected_resource_metadata: ProtectedResourceMetadata | None = None
|
||||
oauth_metadata: OAuthMetadata | None = None
|
||||
auth_server_url: str | None = None
|
||||
|
||||
# Client registration
|
||||
client_info: OAuthClientInformationFull | None = None
|
||||
|
||||
# Token management
|
||||
current_tokens: OAuthToken | None = None
|
||||
token_expiry_time: float | None = None
|
||||
|
||||
# State
|
||||
lock: anyio.Lock = field(default_factory=anyio.Lock)
|
||||
|
||||
def get_authorization_base_url(self, server_url: str) -> str:
|
||||
"""Extract base URL by removing path component."""
|
||||
parsed = urlparse(server_url)
|
||||
return f"{parsed.scheme}://{parsed.netloc}"
|
||||
|
||||
def update_token_expiry(self, token: OAuthToken) -> None:
|
||||
"""Update token expiry time."""
|
||||
if token.expires_in:
|
||||
self.token_expiry_time = time.time() + token.expires_in
|
||||
else:
|
||||
self.token_expiry_time = None
|
||||
|
||||
def is_token_valid(self) -> bool:
|
||||
"""Check if current token is valid."""
|
||||
return bool(
|
||||
self.current_tokens
|
||||
and self.current_tokens.access_token
|
||||
and (not self.token_expiry_time or time.time() <= self.token_expiry_time)
|
||||
)
|
||||
|
||||
def can_refresh_token(self) -> bool:
|
||||
"""Check if token can be refreshed."""
|
||||
return bool(self.current_tokens and self.current_tokens.refresh_token and self.client_info)
|
||||
|
||||
def clear_tokens(self) -> None:
|
||||
"""Clear current tokens."""
|
||||
self.current_tokens = None
|
||||
self.token_expiry_time = None
|
||||
|
||||
|
||||
class OAuthClientProvider(httpx.Auth):
|
||||
"""
|
||||
Authentication for httpx using anyio.
|
||||
OAuth2 authentication for httpx.
|
||||
Handles OAuth flow with automatic client registration and token storage.
|
||||
"""
|
||||
|
||||
requires_response_body = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
server_url: str,
|
||||
|
|
@ -64,407 +152,318 @@ class OAuthClientProvider(httpx.Auth):
|
|||
callback_handler: Callable[[], Awaitable[tuple[str, str | None]]],
|
||||
timeout: float = 300.0,
|
||||
):
|
||||
"""
|
||||
Initialize OAuth2 authentication.
|
||||
"""Initialize OAuth2 authentication."""
|
||||
self.context = OAuthContext(
|
||||
server_url=server_url,
|
||||
client_metadata=client_metadata,
|
||||
storage=storage,
|
||||
redirect_handler=redirect_handler,
|
||||
callback_handler=callback_handler,
|
||||
timeout=timeout,
|
||||
)
|
||||
self._initialized = False
|
||||
|
||||
Args:
|
||||
server_url: Base URL of the OAuth server
|
||||
client_metadata: OAuth client metadata
|
||||
storage: Token storage implementation (defaults to in-memory)
|
||||
redirect_handler: Function to handle authorization URL like opening browser
|
||||
callback_handler: Function to wait for callback
|
||||
and return (auth_code, state)
|
||||
timeout: Timeout for OAuth flow in seconds
|
||||
"""
|
||||
self.server_url = server_url
|
||||
self.client_metadata = client_metadata
|
||||
self.storage = storage
|
||||
self.redirect_handler = redirect_handler
|
||||
self.callback_handler = callback_handler
|
||||
self.timeout = timeout
|
||||
async def _discover_protected_resource(self) -> httpx.Request:
|
||||
"""Build discovery request for protected resource metadata."""
|
||||
auth_base_url = self.context.get_authorization_base_url(self.context.server_url)
|
||||
url = urljoin(auth_base_url, "/.well-known/oauth-protected-resource")
|
||||
return httpx.Request("GET", url, headers={MCP_PROTOCOL_VERSION: LATEST_PROTOCOL_VERSION})
|
||||
|
||||
# Cached authentication state
|
||||
self._current_tokens: OAuthToken | None = None
|
||||
self._metadata: OAuthMetadata | None = None
|
||||
self._client_info: OAuthClientInformationFull | None = None
|
||||
self._token_expiry_time: float | None = None
|
||||
|
||||
# PKCE flow parameters
|
||||
self._code_verifier: str | None = None
|
||||
self._code_challenge: str | None = None
|
||||
|
||||
# State parameter for CSRF protection
|
||||
self._auth_state: str | None = None
|
||||
|
||||
# Thread safety lock
|
||||
self._token_lock = anyio.Lock()
|
||||
|
||||
def _generate_code_verifier(self) -> str:
|
||||
"""Generate a cryptographically random code verifier for PKCE."""
|
||||
return "".join(secrets.choice(string.ascii_letters + string.digits + "-._~") for _ in range(128))
|
||||
|
||||
def _generate_code_challenge(self, code_verifier: str) -> str:
|
||||
"""Generate a code challenge from a code verifier using SHA256."""
|
||||
digest = hashlib.sha256(code_verifier.encode()).digest()
|
||||
return base64.urlsafe_b64encode(digest).decode().rstrip("=")
|
||||
|
||||
def _get_authorization_base_url(self, server_url: str) -> str:
|
||||
"""
|
||||
Extract base URL by removing path component.
|
||||
|
||||
Per MCP spec 2.3.2: https://api.example.com/v1/mcp -> https://api.example.com
|
||||
"""
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
parsed = urlparse(server_url)
|
||||
# Remove path component
|
||||
return urlunparse((parsed.scheme, parsed.netloc, "", "", "", ""))
|
||||
|
||||
async def _discover_oauth_metadata(self, server_url: str) -> OAuthMetadata | None:
|
||||
"""
|
||||
Discover OAuth metadata from server's well-known endpoint.
|
||||
"""
|
||||
# Extract base URL per MCP spec
|
||||
auth_base_url = self._get_authorization_base_url(server_url)
|
||||
url = urljoin(auth_base_url, "/.well-known/oauth-authorization-server")
|
||||
headers = {MCP_PROTOCOL_VERSION: LATEST_PROTOCOL_VERSION}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
async def _handle_protected_resource_response(self, response: httpx.Response) -> None:
|
||||
"""Handle discovery response."""
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
response = await client.get(url, headers=headers)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
metadata_json = response.json()
|
||||
logger.debug(f"OAuth metadata discovered: {metadata_json}")
|
||||
return OAuthMetadata.model_validate(metadata_json)
|
||||
except Exception:
|
||||
# Retry without MCP header for CORS compatibility
|
||||
try:
|
||||
response = await client.get(url)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
metadata_json = response.json()
|
||||
logger.debug(f"OAuth metadata discovered (no MCP header): {metadata_json}")
|
||||
return OAuthMetadata.model_validate(metadata_json)
|
||||
except Exception:
|
||||
logger.exception("Failed to discover OAuth metadata")
|
||||
return None
|
||||
content = await response.aread()
|
||||
metadata = ProtectedResourceMetadata.model_validate_json(content)
|
||||
self.context.protected_resource_metadata = metadata
|
||||
if metadata.authorization_servers:
|
||||
self.context.auth_server_url = str(metadata.authorization_servers[0])
|
||||
except ValidationError:
|
||||
pass
|
||||
|
||||
async def _register_oauth_client(
|
||||
self,
|
||||
server_url: str,
|
||||
client_metadata: OAuthClientMetadata,
|
||||
metadata: OAuthMetadata | None = None,
|
||||
) -> OAuthClientInformationFull:
|
||||
"""
|
||||
Register OAuth client with server.
|
||||
"""
|
||||
if not metadata:
|
||||
metadata = await self._discover_oauth_metadata(server_url)
|
||||
|
||||
if metadata and metadata.registration_endpoint:
|
||||
registration_url = str(metadata.registration_endpoint)
|
||||
async def _discover_oauth_metadata(self) -> httpx.Request:
|
||||
"""Build OAuth metadata discovery request."""
|
||||
if self.context.auth_server_url:
|
||||
base_url = self.context.get_authorization_base_url(self.context.auth_server_url)
|
||||
else:
|
||||
# Use fallback registration endpoint
|
||||
auth_base_url = self._get_authorization_base_url(server_url)
|
||||
base_url = self.context.get_authorization_base_url(self.context.server_url)
|
||||
|
||||
url = urljoin(base_url, "/.well-known/oauth-authorization-server")
|
||||
return httpx.Request("GET", url, headers={MCP_PROTOCOL_VERSION: LATEST_PROTOCOL_VERSION})
|
||||
|
||||
async def _handle_oauth_metadata_response(self, response: httpx.Response) -> None:
|
||||
"""Handle OAuth metadata response."""
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
content = await response.aread()
|
||||
metadata = OAuthMetadata.model_validate_json(content)
|
||||
self.context.oauth_metadata = metadata
|
||||
# Apply default scope if none specified
|
||||
if self.context.client_metadata.scope is None and metadata.scopes_supported is not None:
|
||||
self.context.client_metadata.scope = " ".join(metadata.scopes_supported)
|
||||
except ValidationError:
|
||||
pass
|
||||
|
||||
async def _register_client(self) -> httpx.Request | None:
|
||||
"""Build registration request or skip if already registered."""
|
||||
if self.context.client_info:
|
||||
return None
|
||||
|
||||
if self.context.oauth_metadata and self.context.oauth_metadata.registration_endpoint:
|
||||
registration_url = str(self.context.oauth_metadata.registration_endpoint)
|
||||
else:
|
||||
auth_base_url = self.context.get_authorization_base_url(self.context.server_url)
|
||||
registration_url = urljoin(auth_base_url, "/register")
|
||||
|
||||
# Handle default scope
|
||||
if client_metadata.scope is None and metadata and metadata.scopes_supported is not None:
|
||||
client_metadata.scope = " ".join(metadata.scopes_supported)
|
||||
registration_data = self.context.client_metadata.model_dump(by_alias=True, mode="json", exclude_none=True)
|
||||
|
||||
# Serialize client metadata
|
||||
registration_data = client_metadata.model_dump(by_alias=True, mode="json", exclude_none=True)
|
||||
return httpx.Request(
|
||||
"POST", registration_url, json=registration_data, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
registration_url,
|
||||
json=registration_data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
async def _handle_registration_response(self, response: httpx.Response) -> None:
|
||||
"""Handle registration response."""
|
||||
if response.status_code not in (200, 201):
|
||||
raise OAuthRegistrationError(f"Registration failed: {response.status_code} {response.text}")
|
||||
|
||||
if response.status_code not in (200, 201):
|
||||
raise httpx.HTTPStatusError(
|
||||
f"Registration failed: {response.status_code}",
|
||||
request=response.request,
|
||||
response=response,
|
||||
)
|
||||
try:
|
||||
content = await response.aread()
|
||||
client_info = OAuthClientInformationFull.model_validate_json(content)
|
||||
self.context.client_info = client_info
|
||||
await self.context.storage.set_client_info(client_info)
|
||||
except ValidationError as e:
|
||||
raise OAuthRegistrationError(f"Invalid registration response: {e}")
|
||||
|
||||
response_data = response.json()
|
||||
logger.debug(f"Registration successful: {response_data}")
|
||||
return OAuthClientInformationFull.model_validate(response_data)
|
||||
|
||||
except httpx.HTTPStatusError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Registration error")
|
||||
raise
|
||||
|
||||
async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]:
|
||||
"""
|
||||
HTTPX auth flow integration.
|
||||
"""
|
||||
|
||||
if not self._has_valid_token():
|
||||
await self.initialize()
|
||||
await self.ensure_token()
|
||||
# Add Bearer token if available
|
||||
if self._current_tokens and self._current_tokens.access_token:
|
||||
request.headers["Authorization"] = f"Bearer {self._current_tokens.access_token}"
|
||||
|
||||
response = yield request
|
||||
|
||||
# Clear token on 401 to trigger re-auth
|
||||
if response.status_code == 401:
|
||||
self._current_tokens = None
|
||||
|
||||
def _has_valid_token(self) -> bool:
|
||||
"""Check if current token is valid."""
|
||||
if not self._current_tokens or not self._current_tokens.access_token:
|
||||
return False
|
||||
|
||||
# Check expiry time
|
||||
if self._token_expiry_time and time.time() > self._token_expiry_time:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def _validate_token_scopes(self, token_response: OAuthToken) -> None:
|
||||
"""
|
||||
Validate returned scopes against requested scopes.
|
||||
|
||||
Per OAuth 2.1 Section 3.3: server may grant subset, not superset.
|
||||
"""
|
||||
if not token_response.scope:
|
||||
# No scope returned = validation passes
|
||||
return
|
||||
|
||||
# Check explicitly requested scopes only
|
||||
requested_scopes: set[str] = set()
|
||||
|
||||
if self.client_metadata.scope:
|
||||
# Validate against explicit scope request
|
||||
requested_scopes = set(self.client_metadata.scope.split())
|
||||
|
||||
# Check for unauthorized scopes
|
||||
returned_scopes = set(token_response.scope.split())
|
||||
unauthorized_scopes = returned_scopes - requested_scopes
|
||||
|
||||
if unauthorized_scopes:
|
||||
raise Exception(
|
||||
f"Server granted unauthorized scopes: {unauthorized_scopes}. "
|
||||
f"Requested: {requested_scopes}, Returned: {returned_scopes}"
|
||||
)
|
||||
async def _perform_authorization(self) -> tuple[str, str]:
|
||||
"""Perform the authorization redirect and get auth code."""
|
||||
if self.context.oauth_metadata and self.context.oauth_metadata.authorization_endpoint:
|
||||
auth_endpoint = str(self.context.oauth_metadata.authorization_endpoint)
|
||||
else:
|
||||
# No explicit scopes requested - accept server defaults
|
||||
logger.debug(
|
||||
f"No explicit scopes requested, accepting server-granted "
|
||||
f"scopes: {set(token_response.scope.split())}"
|
||||
)
|
||||
auth_base_url = self.context.get_authorization_base_url(self.context.server_url)
|
||||
auth_endpoint = urljoin(auth_base_url, "/authorize")
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Load stored tokens and client info."""
|
||||
self._current_tokens = await self.storage.get_tokens()
|
||||
self._client_info = await self.storage.get_client_info()
|
||||
if not self.context.client_info:
|
||||
raise OAuthFlowError("No client info available for authorization")
|
||||
|
||||
async def _get_or_register_client(self) -> OAuthClientInformationFull:
|
||||
"""Get or register client with server."""
|
||||
if not self._client_info:
|
||||
try:
|
||||
self._client_info = await self._register_oauth_client(
|
||||
self.server_url, self.client_metadata, self._metadata
|
||||
)
|
||||
await self.storage.set_client_info(self._client_info)
|
||||
except Exception:
|
||||
logger.exception("Client registration failed")
|
||||
raise
|
||||
return self._client_info
|
||||
# Generate PKCE parameters
|
||||
pkce_params = PKCEParameters.generate()
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
async def ensure_token(self) -> None:
|
||||
"""Ensure valid access token, refreshing or re-authenticating as needed."""
|
||||
async with self._token_lock:
|
||||
# Return early if token is valid
|
||||
if self._has_valid_token():
|
||||
return
|
||||
|
||||
# Try refreshing existing token
|
||||
if self._current_tokens and self._current_tokens.refresh_token and await self._refresh_access_token():
|
||||
return
|
||||
|
||||
# Fall back to full OAuth flow
|
||||
await self._perform_oauth_flow()
|
||||
|
||||
async def _perform_oauth_flow(self) -> None:
|
||||
"""Execute OAuth2 authorization code flow with PKCE."""
|
||||
logger.debug("Starting authentication flow.")
|
||||
|
||||
# Discover OAuth metadata
|
||||
if not self._metadata:
|
||||
self._metadata = await self._discover_oauth_metadata(self.server_url)
|
||||
|
||||
# Ensure client registration
|
||||
client_info = await self._get_or_register_client()
|
||||
|
||||
# Generate PKCE challenge
|
||||
self._code_verifier = self._generate_code_verifier()
|
||||
self._code_challenge = self._generate_code_challenge(self._code_verifier)
|
||||
|
||||
# Get authorization endpoint
|
||||
if self._metadata and self._metadata.authorization_endpoint:
|
||||
auth_url_base = str(self._metadata.authorization_endpoint)
|
||||
else:
|
||||
# Use fallback authorization endpoint
|
||||
auth_base_url = self._get_authorization_base_url(self.server_url)
|
||||
auth_url_base = urljoin(auth_base_url, "/authorize")
|
||||
|
||||
# Build authorization URL
|
||||
self._auth_state = secrets.token_urlsafe(32)
|
||||
auth_params = {
|
||||
"response_type": "code",
|
||||
"client_id": client_info.client_id,
|
||||
"redirect_uri": str(self.client_metadata.redirect_uris[0]),
|
||||
"state": self._auth_state,
|
||||
"code_challenge": self._code_challenge,
|
||||
"client_id": self.context.client_info.client_id,
|
||||
"redirect_uri": str(self.context.client_metadata.redirect_uris[0]),
|
||||
"state": state,
|
||||
"code_challenge": pkce_params.code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
|
||||
# Include explicit scopes only
|
||||
if self.client_metadata.scope:
|
||||
auth_params["scope"] = self.client_metadata.scope
|
||||
if self.context.client_metadata.scope:
|
||||
auth_params["scope"] = self.context.client_metadata.scope
|
||||
|
||||
auth_url = f"{auth_url_base}?{urlencode(auth_params)}"
|
||||
authorization_url = f"{auth_endpoint}?{urlencode(auth_params)}"
|
||||
await self.context.redirect_handler(authorization_url)
|
||||
|
||||
# Redirect user for authorization
|
||||
await self.redirect_handler(auth_url)
|
||||
# Wait for callback
|
||||
auth_code, returned_state = await self.context.callback_handler()
|
||||
|
||||
auth_code, returned_state = await self.callback_handler()
|
||||
|
||||
# Validate state parameter for CSRF protection
|
||||
if returned_state is None or not secrets.compare_digest(returned_state, self._auth_state):
|
||||
raise Exception(f"State parameter mismatch: {returned_state} != {self._auth_state}")
|
||||
|
||||
# Clear state after validation
|
||||
self._auth_state = None
|
||||
if returned_state is None or not secrets.compare_digest(returned_state, state):
|
||||
raise OAuthFlowError(f"State parameter mismatch: {returned_state} != {state}")
|
||||
|
||||
if not auth_code:
|
||||
raise Exception("No authorization code received")
|
||||
raise OAuthFlowError("No authorization code received")
|
||||
|
||||
# Exchange authorization code for tokens
|
||||
await self._exchange_code_for_token(auth_code, client_info)
|
||||
# Return auth code and code verifier for token exchange
|
||||
return auth_code, pkce_params.code_verifier
|
||||
|
||||
async def _exchange_code_for_token(self, auth_code: str, client_info: OAuthClientInformationFull) -> None:
|
||||
"""Exchange authorization code for access token."""
|
||||
# Get token endpoint
|
||||
if self._metadata and self._metadata.token_endpoint:
|
||||
token_url = str(self._metadata.token_endpoint)
|
||||
async def _exchange_token(self, auth_code: str, code_verifier: str) -> httpx.Request:
|
||||
"""Build token exchange request."""
|
||||
if not self.context.client_info:
|
||||
raise OAuthFlowError("Missing client info")
|
||||
|
||||
if self.context.oauth_metadata and self.context.oauth_metadata.token_endpoint:
|
||||
token_url = str(self.context.oauth_metadata.token_endpoint)
|
||||
else:
|
||||
# Use fallback token endpoint
|
||||
auth_base_url = self._get_authorization_base_url(self.server_url)
|
||||
auth_base_url = self.context.get_authorization_base_url(self.context.server_url)
|
||||
token_url = urljoin(auth_base_url, "/token")
|
||||
|
||||
token_data = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": auth_code,
|
||||
"redirect_uri": str(self.client_metadata.redirect_uris[0]),
|
||||
"client_id": client_info.client_id,
|
||||
"code_verifier": self._code_verifier,
|
||||
"redirect_uri": str(self.context.client_metadata.redirect_uris[0]),
|
||||
"client_id": self.context.client_info.client_id,
|
||||
"code_verifier": code_verifier,
|
||||
}
|
||||
|
||||
if client_info.client_secret:
|
||||
token_data["client_secret"] = client_info.client_secret
|
||||
if self.context.client_info.client_secret:
|
||||
token_data["client_secret"] = self.context.client_info.client_secret
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
token_url,
|
||||
data=token_data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
timeout=30.0,
|
||||
)
|
||||
return httpx.Request(
|
||||
"POST", token_url, data=token_data, headers={"Content-Type": "application/x-www-form-urlencoded"}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
# Parse OAuth error response
|
||||
try:
|
||||
error_data = response.json()
|
||||
error_msg = error_data.get("error_description", error_data.get("error", "Unknown error"))
|
||||
raise Exception(f"Token exchange failed: {error_msg} " f"(HTTP {response.status_code})")
|
||||
except Exception:
|
||||
raise Exception(f"Token exchange failed: {response.status_code} {response.text}")
|
||||
async def _handle_token_response(self, response: httpx.Response) -> None:
|
||||
"""Handle token exchange response."""
|
||||
if response.status_code != 200:
|
||||
raise OAuthTokenError(f"Token exchange failed: {response.status_code}")
|
||||
|
||||
# Parse token response
|
||||
token_response = OAuthToken.model_validate(response.json())
|
||||
try:
|
||||
content = await response.aread()
|
||||
token_response = OAuthToken.model_validate_json(content)
|
||||
|
||||
# Validate token scopes
|
||||
await self._validate_token_scopes(token_response)
|
||||
# Validate scopes
|
||||
if token_response.scope and self.context.client_metadata.scope:
|
||||
requested_scopes = set(self.context.client_metadata.scope.split())
|
||||
returned_scopes = set(token_response.scope.split())
|
||||
unauthorized_scopes = returned_scopes - requested_scopes
|
||||
if unauthorized_scopes:
|
||||
raise OAuthTokenError(f"Server granted unauthorized scopes: {unauthorized_scopes}")
|
||||
|
||||
# Calculate token expiry
|
||||
if token_response.expires_in:
|
||||
self._token_expiry_time = time.time() + token_response.expires_in
|
||||
else:
|
||||
self._token_expiry_time = None
|
||||
self.context.current_tokens = token_response
|
||||
self.context.update_token_expiry(token_response)
|
||||
await self.context.storage.set_tokens(token_response)
|
||||
except ValidationError as e:
|
||||
raise OAuthTokenError(f"Invalid token response: {e}")
|
||||
|
||||
# Store tokens
|
||||
await self.storage.set_tokens(token_response)
|
||||
self._current_tokens = token_response
|
||||
async def _refresh_token(self) -> httpx.Request:
|
||||
"""Build token refresh request."""
|
||||
if not self.context.current_tokens or not self.context.current_tokens.refresh_token:
|
||||
raise OAuthTokenError("No refresh token available")
|
||||
|
||||
async def _refresh_access_token(self) -> bool:
|
||||
"""Refresh access token using refresh token."""
|
||||
if not self._current_tokens or not self._current_tokens.refresh_token:
|
||||
return False
|
||||
if not self.context.client_info:
|
||||
raise OAuthTokenError("No client info available")
|
||||
|
||||
# Get client credentials
|
||||
client_info = await self._get_or_register_client()
|
||||
|
||||
# Get token endpoint
|
||||
if self._metadata and self._metadata.token_endpoint:
|
||||
token_url = str(self._metadata.token_endpoint)
|
||||
if self.context.oauth_metadata and self.context.oauth_metadata.token_endpoint:
|
||||
token_url = str(self.context.oauth_metadata.token_endpoint)
|
||||
else:
|
||||
# Use fallback token endpoint
|
||||
auth_base_url = self._get_authorization_base_url(self.server_url)
|
||||
auth_base_url = self.context.get_authorization_base_url(self.context.server_url)
|
||||
token_url = urljoin(auth_base_url, "/token")
|
||||
|
||||
refresh_data = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": self._current_tokens.refresh_token,
|
||||
"client_id": client_info.client_id,
|
||||
"refresh_token": self.context.current_tokens.refresh_token,
|
||||
"client_id": self.context.client_info.client_id,
|
||||
}
|
||||
|
||||
if client_info.client_secret:
|
||||
refresh_data["client_secret"] = client_info.client_secret
|
||||
if self.context.client_info.client_secret:
|
||||
refresh_data["client_secret"] = self.context.client_info.client_secret
|
||||
|
||||
return httpx.Request(
|
||||
"POST", token_url, data=refresh_data, headers={"Content-Type": "application/x-www-form-urlencoded"}
|
||||
)
|
||||
|
||||
async def _handle_refresh_response(self, response: httpx.Response) -> bool:
|
||||
"""Handle token refresh response. Returns True if successful."""
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"Token refresh failed: {response.status_code}")
|
||||
self.context.clear_tokens()
|
||||
return False
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
token_url,
|
||||
data=refresh_data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
timeout=30.0,
|
||||
)
|
||||
content = await response.aread()
|
||||
token_response = OAuthToken.model_validate_json(content)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Token refresh failed: {response.status_code}")
|
||||
return False
|
||||
self.context.current_tokens = token_response
|
||||
self.context.update_token_expiry(token_response)
|
||||
await self.context.storage.set_tokens(token_response)
|
||||
|
||||
# Parse refreshed tokens
|
||||
token_response = OAuthToken.model_validate(response.json())
|
||||
|
||||
# Validate token scopes
|
||||
await self._validate_token_scopes(token_response)
|
||||
|
||||
# Calculate token expiry
|
||||
if token_response.expires_in:
|
||||
self._token_expiry_time = time.time() + token_response.expires_in
|
||||
else:
|
||||
self._token_expiry_time = None
|
||||
|
||||
# Store refreshed tokens
|
||||
await self.storage.set_tokens(token_response)
|
||||
self._current_tokens = token_response
|
||||
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
logger.exception("Token refresh failed")
|
||||
return True
|
||||
except ValidationError as e:
|
||||
logger.error(f"Invalid refresh response: {e}")
|
||||
self.context.clear_tokens()
|
||||
return False
|
||||
|
||||
async def _initialize(self) -> None:
|
||||
"""Load stored tokens and client info."""
|
||||
self.context.current_tokens = await self.context.storage.get_tokens()
|
||||
self.context.client_info = await self.context.storage.get_client_info()
|
||||
self._initialized = True
|
||||
|
||||
def _add_auth_header(self, request: httpx.Request) -> None:
|
||||
"""Add authorization header to request if we have valid tokens."""
|
||||
if self.context.current_tokens and self.context.current_tokens.access_token:
|
||||
request.headers["Authorization"] = f"Bearer {self.context.current_tokens.access_token}"
|
||||
|
||||
async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]:
|
||||
"""HTTPX auth flow integration."""
|
||||
async with self.context.lock:
|
||||
if not self._initialized:
|
||||
await self._initialize()
|
||||
|
||||
# Perform OAuth flow if not authenticated
|
||||
if not self.context.is_token_valid():
|
||||
try:
|
||||
# OAuth flow must be inline due to generator constraints
|
||||
# Step 1: Discover protected resource metadata (spec revision 2025-06-18)
|
||||
discovery_request = await self._discover_protected_resource()
|
||||
discovery_response = yield discovery_request
|
||||
await self._handle_protected_resource_response(discovery_response)
|
||||
|
||||
# Step 2: Discover OAuth metadata
|
||||
oauth_request = await self._discover_oauth_metadata()
|
||||
oauth_response = yield oauth_request
|
||||
await self._handle_oauth_metadata_response(oauth_response)
|
||||
|
||||
# Step 3: Register client if needed
|
||||
registration_request = await self._register_client()
|
||||
if registration_request:
|
||||
registration_response = yield registration_request
|
||||
await self._handle_registration_response(registration_response)
|
||||
|
||||
# Step 4: Perform authorization
|
||||
auth_code, code_verifier = await self._perform_authorization()
|
||||
|
||||
# Step 5: Exchange authorization code for tokens
|
||||
token_request = await self._exchange_token(auth_code, code_verifier)
|
||||
token_response = yield token_request
|
||||
await self._handle_token_response(token_response)
|
||||
except Exception as e:
|
||||
logger.error(f"OAuth flow error: {e}")
|
||||
raise
|
||||
|
||||
# Add authorization header and make request
|
||||
self._add_auth_header(request)
|
||||
response = yield request
|
||||
|
||||
# Handle 401 responses
|
||||
if response.status_code == 401 and self.context.can_refresh_token():
|
||||
# Try to refresh token
|
||||
refresh_request = await self._refresh_token()
|
||||
refresh_response = yield refresh_request
|
||||
|
||||
if await self._handle_refresh_response(refresh_response):
|
||||
# Retry original request with new token
|
||||
self._add_auth_header(request)
|
||||
yield request
|
||||
else:
|
||||
# Refresh failed, need full re-authentication
|
||||
self._initialized = False
|
||||
|
||||
# OAuth flow must be inline due to generator constraints
|
||||
# Step 1: Discover protected resource metadata (spec revision 2025-06-18)
|
||||
discovery_request = await self._discover_protected_resource()
|
||||
discovery_response = yield discovery_request
|
||||
await self._handle_protected_resource_response(discovery_response)
|
||||
|
||||
# Step 2: Discover OAuth metadata
|
||||
oauth_request = await self._discover_oauth_metadata()
|
||||
oauth_response = yield oauth_request
|
||||
await self._handle_oauth_metadata_response(oauth_response)
|
||||
|
||||
# Step 3: Register client if needed
|
||||
registration_request = await self._register_client()
|
||||
if registration_request:
|
||||
registration_response = yield registration_request
|
||||
await self._handle_registration_response(registration_response)
|
||||
|
||||
# Step 4: Perform authorization
|
||||
auth_code, code_verifier = await self._perform_authorization()
|
||||
|
||||
# Step 5: Exchange authorization code for tokens
|
||||
token_request = await self._exchange_token(auth_code, code_verifier)
|
||||
token_response = yield token_request
|
||||
await self._handle_token_response(token_response)
|
||||
|
||||
# Retry with new tokens
|
||||
self._add_auth_header(request)
|
||||
yield request
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from starlette.requests import Request
|
|||
from starlette.responses import Response
|
||||
|
||||
from mcp.server.auth.json_response import PydanticJSONResponse
|
||||
from mcp.shared.auth import OAuthMetadata
|
||||
from mcp.shared.auth import OAuthMetadata, ProtectedResourceMetadata
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -16,3 +16,14 @@ class MetadataHandler:
|
|||
content=self.metadata,
|
||||
headers={"Cache-Control": "public, max-age=3600"}, # Cache for 1 hour
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProtectedResourceMetadataHandler:
|
||||
metadata: ProtectedResourceMetadata
|
||||
|
||||
async def handle(self, request: Request) -> Response:
|
||||
return PydanticJSONResponse(
|
||||
content=self.metadata,
|
||||
headers={"Cache-Control": "public, max-age=3600"}, # Cache for 1 hour
|
||||
)
|
||||
|
|
|
|||
|
|
@ -151,7 +151,14 @@ class TokenHandler:
|
|||
authorize_request_redirect_uri = auth_code.redirect_uri
|
||||
else:
|
||||
authorize_request_redirect_uri = None
|
||||
if token_request.redirect_uri != authorize_request_redirect_uri:
|
||||
|
||||
# Convert both sides to strings for comparison to handle AnyUrl vs string issues
|
||||
token_redirect_str = str(token_request.redirect_uri) if token_request.redirect_uri is not None else None
|
||||
auth_redirect_str = (
|
||||
str(authorize_request_redirect_uri) if authorize_request_redirect_uri is not None else None
|
||||
)
|
||||
|
||||
if token_redirect_str != auth_redirect_str:
|
||||
return self.response(
|
||||
TokenErrorResponse(
|
||||
error="invalid_request",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from pydantic import AnyHttpUrl
|
||||
from starlette.authentication import AuthCredentials, AuthenticationBackend, SimpleUser
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.requests import HTTPConnection
|
||||
from starlette.types import Receive, Scope, Send
|
||||
|
||||
from mcp.server.auth.provider import AccessToken, OAuthAuthorizationServerProvider
|
||||
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
||||
|
||||
|
||||
class AuthenticatedUser(SimpleUser):
|
||||
|
|
@ -20,14 +21,11 @@ class AuthenticatedUser(SimpleUser):
|
|||
|
||||
class BearerAuthBackend(AuthenticationBackend):
|
||||
"""
|
||||
Authentication backend that validates Bearer tokens.
|
||||
Authentication backend that validates Bearer tokens using a TokenVerifier.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider: OAuthAuthorizationServerProvider[Any, Any, Any],
|
||||
):
|
||||
self.provider = provider
|
||||
def __init__(self, token_verifier: TokenVerifier):
|
||||
self.token_verifier = token_verifier
|
||||
|
||||
async def authenticate(self, conn: HTTPConnection):
|
||||
auth_header = next(
|
||||
|
|
@ -39,8 +37,8 @@ class BearerAuthBackend(AuthenticationBackend):
|
|||
|
||||
token = auth_header[7:] # Remove "Bearer " prefix
|
||||
|
||||
# Validate the token with the provider
|
||||
auth_info = await self.provider.load_access_token(token)
|
||||
# Validate the token with the verifier
|
||||
auth_info = await self.token_verifier.verify_token(token)
|
||||
|
||||
if not auth_info:
|
||||
return None
|
||||
|
|
@ -59,27 +57,72 @@ class RequireAuthMiddleware:
|
|||
auth info in the request state.
|
||||
"""
|
||||
|
||||
def __init__(self, app: Any, required_scopes: list[str]):
|
||||
def __init__(
|
||||
self,
|
||||
app: Any,
|
||||
required_scopes: list[str],
|
||||
resource_metadata_url: AnyHttpUrl | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize the middleware.
|
||||
|
||||
Args:
|
||||
app: ASGI application
|
||||
provider: Authentication provider to validate tokens
|
||||
required_scopes: Optional list of scopes that the token must have
|
||||
required_scopes: List of scopes that the token must have
|
||||
resource_metadata_url: Optional protected resource metadata URL for WWW-Authenticate header
|
||||
"""
|
||||
self.app = app
|
||||
self.required_scopes = required_scopes
|
||||
self.resource_metadata_url = resource_metadata_url
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
auth_user = scope.get("user")
|
||||
if not isinstance(auth_user, AuthenticatedUser):
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
await self._send_auth_error(
|
||||
send, status_code=401, error="invalid_token", description="Authentication required"
|
||||
)
|
||||
return
|
||||
|
||||
auth_credentials = scope.get("auth")
|
||||
|
||||
for required_scope in self.required_scopes:
|
||||
# auth_credentials should always be provided; this is just paranoia
|
||||
if auth_credentials is None or required_scope not in auth_credentials.scopes:
|
||||
raise HTTPException(status_code=403, detail="Insufficient scope")
|
||||
await self._send_auth_error(
|
||||
send, status_code=403, error="insufficient_scope", description=f"Required scope: {required_scope}"
|
||||
)
|
||||
return
|
||||
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
async def _send_auth_error(self, send: Send, status_code: int, error: str, description: str) -> None:
|
||||
"""Send an authentication error response with WWW-Authenticate header."""
|
||||
# Build WWW-Authenticate header value
|
||||
www_auth_parts = [f'error="{error}"', f'error_description="{description}"']
|
||||
if self.resource_metadata_url:
|
||||
www_auth_parts.append(f'resource_metadata="{self.resource_metadata_url}"')
|
||||
|
||||
www_authenticate = f"Bearer {', '.join(www_auth_parts)}"
|
||||
|
||||
# Send response
|
||||
body = {"error": error, "error_description": description}
|
||||
body_bytes = json.dumps(body).encode()
|
||||
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.start",
|
||||
"status": status_code,
|
||||
"headers": [
|
||||
(b"content-type", b"application/json"),
|
||||
(b"content-length", str(len(body_bytes)).encode()),
|
||||
(b"www-authenticate", www_authenticate.encode()),
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.body",
|
||||
"body": body_bytes,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -86,6 +86,13 @@ class TokenError(Exception):
|
|||
error_description: str | None = None
|
||||
|
||||
|
||||
class TokenVerifier(Protocol):
|
||||
"""Protocol for verifying bearer tokens."""
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""Verify a bearer token and return access info if valid."""
|
||||
|
||||
|
||||
# NOTE: FastMCP doesn't render any of these types in the user response, so it's
|
||||
# OK to add fields to subclasses which should not be exposed externally.
|
||||
AuthorizationCodeT = TypeVar("AuthorizationCodeT", bound=AuthorizationCode)
|
||||
|
|
@ -278,3 +285,19 @@ def construct_redirect_uri(redirect_uri_base: str, **params: str | None) -> str:
|
|||
|
||||
redirect_uri = urlunparse(parsed_uri._replace(query=urlencode(query_params)))
|
||||
return redirect_uri
|
||||
|
||||
|
||||
class ProviderTokenVerifier(TokenVerifier):
|
||||
"""Token verifier that uses an OAuthAuthorizationServerProvider.
|
||||
|
||||
This is provided for backwards compatibility with existing auth_server_provider
|
||||
configurations. For new implementations using AS/RS separation, consider using
|
||||
the TokenVerifier protocol with a dedicated implementation like IntrospectionTokenVerifier.
|
||||
"""
|
||||
|
||||
def __init__(self, provider: "OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]"):
|
||||
self.provider = provider
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""Verify token using the provider's load_access_token method."""
|
||||
return await self.provider.load_access_token(token)
|
||||
|
|
|
|||
|
|
@ -180,3 +180,40 @@ def build_metadata(
|
|||
metadata.revocation_endpoint_auth_methods_supported = ["client_secret_post"]
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
def create_protected_resource_routes(
|
||||
resource_url: AnyHttpUrl,
|
||||
authorization_servers: list[AnyHttpUrl],
|
||||
scopes_supported: list[str] | None = None,
|
||||
) -> list[Route]:
|
||||
"""
|
||||
Create routes for OAuth 2.0 Protected Resource Metadata (RFC 9728).
|
||||
|
||||
Args:
|
||||
resource_url: The URL of this resource server
|
||||
authorization_servers: List of authorization servers that can issue tokens
|
||||
scopes_supported: Optional list of scopes supported by this resource
|
||||
|
||||
Returns:
|
||||
List of Starlette routes for protected resource metadata
|
||||
"""
|
||||
from mcp.server.auth.handlers.metadata import ProtectedResourceMetadataHandler
|
||||
from mcp.shared.auth import ProtectedResourceMetadata
|
||||
|
||||
metadata = ProtectedResourceMetadata(
|
||||
resource=resource_url,
|
||||
authorization_servers=authorization_servers,
|
||||
scopes_supported=scopes_supported,
|
||||
# bearer_methods_supported defaults to ["header"] in the model
|
||||
)
|
||||
|
||||
handler = ProtectedResourceMetadataHandler(metadata)
|
||||
|
||||
return [
|
||||
Route(
|
||||
"/.well-known/oauth-protected-resource",
|
||||
endpoint=cors_middleware(handler.handle, ["GET", "OPTIONS"]),
|
||||
methods=["GET", "OPTIONS"],
|
||||
)
|
||||
]
|
||||
|
|
|
|||
|
|
@ -15,9 +15,16 @@ class RevocationOptions(BaseModel):
|
|||
class AuthSettings(BaseModel):
|
||||
issuer_url: AnyHttpUrl = Field(
|
||||
...,
|
||||
description="URL advertised as OAuth issuer; this should be the URL the server " "is reachable at",
|
||||
description="OAuth authorization server URL that issues tokens for this resource server.",
|
||||
)
|
||||
service_documentation_url: AnyHttpUrl | None = None
|
||||
client_registration_options: ClientRegistrationOptions | None = None
|
||||
revocation_options: RevocationOptions | None = None
|
||||
required_scopes: list[str] | None = None
|
||||
|
||||
# Resource Server settings (when operating as RS only)
|
||||
resource_server_url: AnyHttpUrl | None = Field(
|
||||
...,
|
||||
description="The URL of the MCP server to be used as the resource identifier "
|
||||
"and base route to look up OAuth Protected Resource Metadata.",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -30,10 +30,8 @@ from mcp.server.auth.middleware.bearer_auth import (
|
|||
BearerAuthBackend,
|
||||
RequireAuthMiddleware,
|
||||
)
|
||||
from mcp.server.auth.provider import OAuthAuthorizationServerProvider
|
||||
from mcp.server.auth.settings import (
|
||||
AuthSettings,
|
||||
)
|
||||
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, ProviderTokenVerifier, TokenVerifier
|
||||
from mcp.server.auth.settings import AuthSettings
|
||||
from mcp.server.elicitation import ElicitationResult, ElicitSchemaModelT, elicit_with_validation
|
||||
from mcp.server.fastmcp.exceptions import ResourceError
|
||||
from mcp.server.fastmcp.prompts import Prompt, PromptManager
|
||||
|
|
@ -141,6 +139,7 @@ class FastMCP:
|
|||
name: str | None = None,
|
||||
instructions: str | None = None,
|
||||
auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None,
|
||||
token_verifier: TokenVerifier | None = None,
|
||||
event_store: EventStore | None = None,
|
||||
*,
|
||||
tools: list[Tool] | None = None,
|
||||
|
|
@ -156,14 +155,22 @@ class FastMCP:
|
|||
self._tool_manager = ToolManager(tools=tools, warn_on_duplicate_tools=self.settings.warn_on_duplicate_tools)
|
||||
self._resource_manager = ResourceManager(warn_on_duplicate_resources=self.settings.warn_on_duplicate_resources)
|
||||
self._prompt_manager = PromptManager(warn_on_duplicate_prompts=self.settings.warn_on_duplicate_prompts)
|
||||
if (self.settings.auth is not None) != (auth_server_provider is not None):
|
||||
# TODO: after we support separate authorization servers (see
|
||||
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/284)
|
||||
# we should validate that if auth is enabled, we have either an
|
||||
# auth_server_provider to host our own authorization server,
|
||||
# OR the URL of a 3rd party authorization server.
|
||||
raise ValueError("settings.auth must be specified if and only if auth_server_provider " "is specified")
|
||||
# Validate auth configuration
|
||||
if self.settings.auth is not None:
|
||||
if auth_server_provider and token_verifier:
|
||||
raise ValueError("Cannot specify both auth_server_provider and token_verifier")
|
||||
if not auth_server_provider and not token_verifier:
|
||||
raise ValueError("Must specify either auth_server_provider or token_verifier when auth is enabled")
|
||||
else:
|
||||
if auth_server_provider or token_verifier:
|
||||
raise ValueError("Cannot specify auth_server_provider or token_verifier without auth settings")
|
||||
|
||||
self._auth_server_provider = auth_server_provider
|
||||
self._token_verifier = token_verifier
|
||||
|
||||
# Create token verifier from provider if needed (backwards compatibility)
|
||||
if auth_server_provider and not token_verifier:
|
||||
self._token_verifier = ProviderTokenVerifier(auth_server_provider)
|
||||
self._event_store = event_store
|
||||
self._custom_starlette_routes: list[Route] = []
|
||||
self.dependencies = self.settings.dependencies
|
||||
|
|
@ -701,49 +708,60 @@ class FastMCP:
|
|||
middleware: list[Middleware] = []
|
||||
required_scopes = []
|
||||
|
||||
# Add auth endpoints if auth provider is configured
|
||||
if self._auth_server_provider:
|
||||
assert self.settings.auth
|
||||
from mcp.server.auth.routes import create_auth_routes
|
||||
|
||||
# Set up auth if configured
|
||||
if self.settings.auth:
|
||||
required_scopes = self.settings.auth.required_scopes or []
|
||||
|
||||
middleware = [
|
||||
# extract auth info from request (but do not require it)
|
||||
Middleware(
|
||||
AuthenticationMiddleware,
|
||||
backend=BearerAuthBackend(
|
||||
provider=self._auth_server_provider,
|
||||
# Add auth middleware if token verifier is available
|
||||
if self._token_verifier:
|
||||
middleware = [
|
||||
# extract auth info from request (but do not require it)
|
||||
Middleware(
|
||||
AuthenticationMiddleware,
|
||||
backend=BearerAuthBackend(self._token_verifier),
|
||||
),
|
||||
),
|
||||
# Add the auth context middleware to store
|
||||
# authenticated user in a contextvar
|
||||
Middleware(AuthContextMiddleware),
|
||||
]
|
||||
routes.extend(
|
||||
create_auth_routes(
|
||||
provider=self._auth_server_provider,
|
||||
issuer_url=self.settings.auth.issuer_url,
|
||||
service_documentation_url=self.settings.auth.service_documentation_url,
|
||||
client_registration_options=self.settings.auth.client_registration_options,
|
||||
revocation_options=self.settings.auth.revocation_options,
|
||||
)
|
||||
)
|
||||
# Add the auth context middleware to store
|
||||
# authenticated user in a contextvar
|
||||
Middleware(AuthContextMiddleware),
|
||||
]
|
||||
|
||||
# Add auth endpoints if auth server provider is configured
|
||||
if self._auth_server_provider:
|
||||
from mcp.server.auth.routes import create_auth_routes
|
||||
|
||||
routes.extend(
|
||||
create_auth_routes(
|
||||
provider=self._auth_server_provider,
|
||||
issuer_url=self.settings.auth.issuer_url,
|
||||
service_documentation_url=self.settings.auth.service_documentation_url,
|
||||
client_registration_options=self.settings.auth.client_registration_options,
|
||||
revocation_options=self.settings.auth.revocation_options,
|
||||
)
|
||||
)
|
||||
|
||||
# When auth is configured, require authentication
|
||||
if self._token_verifier:
|
||||
# Determine resource metadata URL
|
||||
resource_metadata_url = None
|
||||
if self.settings.auth and self.settings.auth.resource_server_url:
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
resource_metadata_url = AnyHttpUrl(
|
||||
str(self.settings.auth.resource_server_url).rstrip("/") + "/.well-known/oauth-protected-resource"
|
||||
)
|
||||
|
||||
# When auth is not configured, we shouldn't require auth
|
||||
if self._auth_server_provider:
|
||||
# Auth is enabled, wrap the endpoints with RequireAuthMiddleware
|
||||
routes.append(
|
||||
Route(
|
||||
self.settings.sse_path,
|
||||
endpoint=RequireAuthMiddleware(handle_sse, required_scopes),
|
||||
endpoint=RequireAuthMiddleware(handle_sse, required_scopes, resource_metadata_url),
|
||||
methods=["GET"],
|
||||
)
|
||||
)
|
||||
routes.append(
|
||||
Mount(
|
||||
self.settings.message_path,
|
||||
app=RequireAuthMiddleware(sse.handle_post_message, required_scopes),
|
||||
app=RequireAuthMiddleware(sse.handle_post_message, required_scopes, resource_metadata_url),
|
||||
)
|
||||
)
|
||||
else:
|
||||
|
|
@ -766,6 +784,18 @@ class FastMCP:
|
|||
app=sse.handle_post_message,
|
||||
)
|
||||
)
|
||||
# Add protected resource metadata endpoint if configured as RS
|
||||
if self.settings.auth and self.settings.auth.resource_server_url:
|
||||
from mcp.server.auth.routes import create_protected_resource_routes
|
||||
|
||||
routes.extend(
|
||||
create_protected_resource_routes(
|
||||
resource_url=self.settings.auth.resource_server_url,
|
||||
authorization_servers=[self.settings.auth.issuer_url],
|
||||
scopes_supported=self.settings.auth.required_scopes,
|
||||
)
|
||||
)
|
||||
|
||||
# mount these routes last, so they have the lowest route matching precedence
|
||||
routes.extend(self._custom_starlette_routes)
|
||||
|
||||
|
|
@ -796,35 +826,49 @@ class FastMCP:
|
|||
middleware: list[Middleware] = []
|
||||
required_scopes = []
|
||||
|
||||
# Add auth endpoints if auth provider is configured
|
||||
if self._auth_server_provider:
|
||||
assert self.settings.auth
|
||||
from mcp.server.auth.routes import create_auth_routes
|
||||
|
||||
# Set up auth if configured
|
||||
if self.settings.auth:
|
||||
required_scopes = self.settings.auth.required_scopes or []
|
||||
|
||||
middleware = [
|
||||
Middleware(
|
||||
AuthenticationMiddleware,
|
||||
backend=BearerAuthBackend(
|
||||
provider=self._auth_server_provider,
|
||||
# Add auth middleware if token verifier is available
|
||||
if self._token_verifier:
|
||||
middleware = [
|
||||
Middleware(
|
||||
AuthenticationMiddleware,
|
||||
backend=BearerAuthBackend(self._token_verifier),
|
||||
),
|
||||
),
|
||||
Middleware(AuthContextMiddleware),
|
||||
]
|
||||
routes.extend(
|
||||
create_auth_routes(
|
||||
provider=self._auth_server_provider,
|
||||
issuer_url=self.settings.auth.issuer_url,
|
||||
service_documentation_url=self.settings.auth.service_documentation_url,
|
||||
client_registration_options=self.settings.auth.client_registration_options,
|
||||
revocation_options=self.settings.auth.revocation_options,
|
||||
Middleware(AuthContextMiddleware),
|
||||
]
|
||||
|
||||
# Add auth endpoints if auth server provider is configured
|
||||
if self._auth_server_provider:
|
||||
from mcp.server.auth.routes import create_auth_routes
|
||||
|
||||
routes.extend(
|
||||
create_auth_routes(
|
||||
provider=self._auth_server_provider,
|
||||
issuer_url=self.settings.auth.issuer_url,
|
||||
service_documentation_url=self.settings.auth.service_documentation_url,
|
||||
client_registration_options=self.settings.auth.client_registration_options,
|
||||
revocation_options=self.settings.auth.revocation_options,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Set up routes with or without auth
|
||||
if self._token_verifier:
|
||||
# Determine resource metadata URL
|
||||
resource_metadata_url = None
|
||||
if self.settings.auth and self.settings.auth.resource_server_url:
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
resource_metadata_url = AnyHttpUrl(
|
||||
str(self.settings.auth.resource_server_url).rstrip("/") + "/.well-known/oauth-protected-resource"
|
||||
)
|
||||
|
||||
routes.append(
|
||||
Mount(
|
||||
self.settings.streamable_http_path,
|
||||
app=RequireAuthMiddleware(handle_streamable_http, required_scopes),
|
||||
app=RequireAuthMiddleware(handle_streamable_http, required_scopes, resource_metadata_url),
|
||||
)
|
||||
)
|
||||
else:
|
||||
|
|
@ -836,6 +880,28 @@ class FastMCP:
|
|||
)
|
||||
)
|
||||
|
||||
# Add protected resource metadata endpoint if configured as RS
|
||||
if self.settings.auth and self.settings.auth.resource_server_url:
|
||||
from mcp.server.auth.handlers.metadata import ProtectedResourceMetadataHandler
|
||||
from mcp.server.auth.routes import cors_middleware
|
||||
from mcp.shared.auth import ProtectedResourceMetadata
|
||||
|
||||
protected_resource_metadata = ProtectedResourceMetadata(
|
||||
resource=self.settings.auth.resource_server_url,
|
||||
authorization_servers=[self.settings.auth.issuer_url],
|
||||
scopes_supported=self.settings.auth.required_scopes,
|
||||
)
|
||||
routes.append(
|
||||
Route(
|
||||
"/.well-known/oauth-protected-resource",
|
||||
endpoint=cors_middleware(
|
||||
ProtectedResourceMetadataHandler(protected_resource_metadata).handle,
|
||||
["GET", "OPTIONS"],
|
||||
),
|
||||
methods=["GET", "OPTIONS"],
|
||||
)
|
||||
)
|
||||
|
||||
routes.extend(self._custom_starlette_routes)
|
||||
|
||||
return Starlette(
|
||||
|
|
|
|||
|
|
@ -129,3 +129,16 @@ class OAuthMetadata(BaseModel):
|
|||
introspection_endpoint_auth_methods_supported: list[str] | None = None
|
||||
introspection_endpoint_auth_signing_alg_values_supported: None = None
|
||||
code_challenge_methods_supported: list[str] | None = None
|
||||
|
||||
|
||||
class ProtectedResourceMetadata(BaseModel):
|
||||
"""
|
||||
RFC 9728 OAuth 2.0 Protected Resource Metadata.
|
||||
See https://datatracker.ietf.org/doc/html/rfc9728#section-2
|
||||
"""
|
||||
|
||||
resource: AnyHttpUrl
|
||||
authorization_servers: list[AnyHttpUrl] = Field(..., min_length=1)
|
||||
scopes_supported: list[str] | None = None
|
||||
bearer_methods_supported: list[str] | None = Field(default=["header"]) # MCP only supports header method
|
||||
resource_documentation: AnyHttpUrl | None = None
|
||||
|
|
|
|||
Loading…
Reference in a new issue