mirror of
https://github.com/mcp-use/mcp-use.git
synced 2026-09-14 18:06:01 +08:00
aca5f9c040
* feat: Add PRM in the metadata discovery * fix: fallback anyway to PRM * feat: Implement complete discovery logic * feat: Final implementation of CIMD * feat: Implement PKCE * feat: Add scope from WWW-Authenticate * feat: Add all URLs possible styles * feat: Update tests and example * docs: Update docs * fix: linting * docs: update API documentation * fix: correct typos in OAuth metadata and error messages - Updated the comment in ProtectedResourceMetadata to use the correct abbreviation (PRM). - Fixed a typo in the OAuthAuthenticationError message regarding code challenge support. - Corrected the comment for legacy client paths to use "pre-registered" instead of "pre-reggistered". * feat: restructure authentication documentation and add new methods - Updated the authentication section in to group authentication methods under a new Authentication category with an icon. - Removed the old file and replaced it with new documentation files for Bearer Token, Custom Auth, and OAuth 2.1. - Introduced an overview page for authentication methods, detailing usage and configuration for each method. - Enhanced security best practices and examples for each authentication type to improve clarity and usability. * refactor: improve debug logging for OAuth metadata discovery - Updated debug messages to provide clearer context by specifying the issuer or host being queried during OAuth and OpenID Connect metadata discovery. - Enhanced error logging to remove redundant information, focusing on the failure reason without repeating the URL. * fix: correct abbreviation in ProtectedResourceMetadata documentation - Updated the abbreviation from PRC to PRM in the ProtectedResourceMetadata section to ensure accuracy in the documentation. * fix(oauth): correct RFC 8414 well-known URL construction for path-based issuers The OAuth metadata discovery was incorrectly constructing .well-known URLs by appending to the issuer instead of inserting between host and path. RFC 8414 specifies that for an issuer like https://github.com/login/oauth, the .well-known URL should be: https://github.com/.well-known/oauth-authorization-server/login/oauth We were incorrectly building: https://github.com/login/oauth/.well-known/oauth-authorization-server This fix: - Implements correct RFC 8414 URL construction (insert between host/path) - Supports OpenID Connect style as fallback (append to issuer) - Removes GitHub-specific hardcode since discovery now works correctly - Adds documentation comments explaining the two standards Ref: https://www.rfc-editor.org/rfc/rfc8414.html#section-3 * chore: oauth examples * docs: enhance OAuth endpoint discovery documentation - Updated the OAuth documentation to clarify how `mcp-use` discovers OAuth endpoints using both OAuth 2.0 and OpenID Connect standards. - Added a table detailing the URL construction methods for each standard. - Included examples for better understanding and a note on fallback behavior when the issuer has no path. * response to bs codeql * fix: update API URL Updated the GitHub API URL in the configuration to the correct endpoint for OAuth operations. --------- Co-authored-by: Enrico Toniato <2827496+tonxxd@users.noreply.github.com> Co-authored-by: pietrozullo <pietro.zullo@gmail.com>
806 lines
36 KiB
Python
806 lines
36 KiB
Python
"""OAuth authentication support for MCP clients."""
|
||
|
||
import base64
|
||
import hashlib
|
||
import json
|
||
import re
|
||
import secrets
|
||
import webbrowser
|
||
from datetime import UTC, datetime, timedelta
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from urllib.parse import urlparse
|
||
|
||
import httpx
|
||
from authlib.integrations.httpx_client import AsyncOAuth2Client
|
||
from authlib.oauth2 import OAuth2Error
|
||
from pydantic import BaseModel, Field, HttpUrl, SecretStr
|
||
|
||
from mcp_use.client.auth.bearer import BearerAuth
|
||
from mcp_use.client.auth.oauth_callback import OAuthCallbackServer
|
||
from mcp_use.client.exceptions import OAuthAuthenticationError, OAuthDiscoveryError
|
||
from mcp_use.logging import logger
|
||
from mcp_use.telemetry.telemetry import telemetry
|
||
|
||
|
||
class ServerOAuthMetadata(BaseModel):
|
||
"""OAuth metadata from MCP server with flexible field support.
|
||
It is essentially a configuration that tells MCP client:
|
||
|
||
- Where to send users for authorization
|
||
- Where to exchange the codes for tokens
|
||
- Which OAuth features are supported
|
||
- Where to register new users with DCR"""
|
||
|
||
issuer: HttpUrl # The OAuth server's identity
|
||
authorization_endpoint: HttpUrl # URL with endpoint for client auth
|
||
token_endpoint: HttpUrl # URL with endpoint for tokens' exchange
|
||
userinfo_endpoint: HttpUrl | None = None
|
||
revocation_endpoint: HttpUrl | None = None
|
||
introspection_endpoint: HttpUrl | None = None
|
||
registration_endpoint: HttpUrl | None = None # Endpoint for DCR
|
||
jwks_uri: HttpUrl | None = None
|
||
response_types_supported: list[str] = Field(default_factory=lambda: ["code"])
|
||
subject_types_supported: list[str] = Field(default_factory=lambda: ["public"])
|
||
id_token_signing_alg_values_supported: list[str] = Field(default_factory=lambda: ["RS256"])
|
||
scopes_supported: list[str] | None = None # Which permissions are supported
|
||
token_endpoint_auth_methods_supported: list[str] = Field(default_factory=lambda: ["client_secret_basic"])
|
||
claims_supported: list[str] | None = None
|
||
code_challenge_methods_supported: list[str] | None = None
|
||
client_id_metadata_document_supported: bool | None = None
|
||
|
||
class Config:
|
||
extra = "allow" # Allow additional fields
|
||
|
||
|
||
class ProtectedResourceMetadata(BaseModel):
|
||
"""
|
||
PRM (Protected Resource Metadata) can have metadata
|
||
describing their configuration. It could contain information
|
||
about the OAuth metadata.
|
||
"""
|
||
|
||
resource: str
|
||
authorization_servers: list[str]
|
||
scopes_supported: list[str] | None = None
|
||
|
||
|
||
class OAuthClientProvider(BaseModel):
|
||
"""OAuth client provider configuration for a specific server.
|
||
|
||
This contains all the information needed to authenticate with an OAuth server
|
||
without needing to discover metadata or register clients dynamically."""
|
||
|
||
id: str # Unique identifier
|
||
display_name: str
|
||
metadata: ServerOAuthMetadata | dict[str, Any]
|
||
|
||
@property
|
||
def oauth_metadata(self) -> ServerOAuthMetadata:
|
||
"""Get OAuth metadata as ServerOAuthMetadata instance."""
|
||
if isinstance(self.metadata, dict):
|
||
return ServerOAuthMetadata(**self.metadata)
|
||
return self.metadata
|
||
|
||
|
||
class TokenData(BaseModel):
|
||
"""OAuth token data.
|
||
|
||
This is the information received after
|
||
successfull authentication"""
|
||
|
||
access_token: str # Actual credential used for requests
|
||
token_type: str = "Bearer"
|
||
expires_at: float | None = None
|
||
refresh_token: str | None = None
|
||
scope: str | None = None
|
||
|
||
|
||
class ClientRegistrationResponse(BaseModel):
|
||
"""Dynamic Client Registration response.
|
||
|
||
It represents the response from an OAuth server
|
||
when you dinamically register a new OAuth client."""
|
||
|
||
client_id: str
|
||
client_secret: str | None = None
|
||
client_id_issued_at: int | None = None
|
||
client_secret_expires_at: int | None = None
|
||
redirect_uris: list[str] | None = None # Where auth server should redirect after auth
|
||
grant_types: list[str] | None = None # Which oauth flows it uses
|
||
response_types: list[str] | None = None
|
||
client_name: str | None = None
|
||
token_endpoint_auth_method: str | None = None
|
||
|
||
class Config:
|
||
extra = "allow" # Allow additional fields from server
|
||
|
||
|
||
class FileTokenStorage:
|
||
"""File-based token storage.
|
||
|
||
It's responsible for:
|
||
|
||
- Saving OAuth tokens to disk after auth
|
||
- Loading saved tokens when the app restarts
|
||
- Deleting tokens when they're revoked
|
||
- Organizing tokens by server URL"""
|
||
|
||
def __init__(self, base_dir: Path | None = None):
|
||
"""Initialize token storage.
|
||
|
||
Args:
|
||
base_dir: Base directory for token storage. Defaults to ~/.mcp_use/tokens
|
||
"""
|
||
self.base_dir = base_dir or Path.home() / ".mcp_use" / "tokens"
|
||
logger.debug(f"FileTokenStorage initialized with base_dir: {self.base_dir}")
|
||
self.base_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
def _get_token_path(self, server_url: str) -> Path:
|
||
"""Get token file path for a server."""
|
||
# Create a safe filename from the URL
|
||
parsed = urlparse(server_url)
|
||
filename = f"{parsed.netloc}_{parsed.path.replace('/', '_')}.json"
|
||
path = self.base_dir / filename
|
||
logger.debug(f"Token path for server '{server_url}' is '{path}'")
|
||
return path
|
||
|
||
async def save_tokens(self, server_url: str, tokens: dict[str, Any]) -> None:
|
||
"""Save tokens to file."""
|
||
token_path = self._get_token_path(server_url)
|
||
logger.debug(f"Saving tokens for '{server_url}' to '{token_path}'")
|
||
token_data = TokenData(**tokens)
|
||
token_path.write_text(token_data.model_dump_json())
|
||
logger.debug(f"Tokens saved successfully for '{server_url}'")
|
||
|
||
async def load_tokens(self, server_url: str) -> TokenData | None:
|
||
"""Load tokens from file."""
|
||
token_path = self._get_token_path(server_url)
|
||
logger.debug(f"Attempting to load tokens for '{server_url}' from '{token_path}'")
|
||
if not token_path.exists():
|
||
logger.debug(f"Token file not found: '{token_path}'")
|
||
return None
|
||
|
||
try:
|
||
data = json.loads(token_path.read_text())
|
||
token_data = TokenData(**data)
|
||
logger.debug(f"Successfully loaded tokens for '{server_url}'")
|
||
return token_data
|
||
except (json.JSONDecodeError, ValueError) as e:
|
||
logger.debug(f"Failed to load or parse token file '{token_path}': {e}")
|
||
return None
|
||
|
||
async def delete_tokens(self, server_url: str) -> None:
|
||
"""Delete tokens for a server."""
|
||
token_path = self._get_token_path(server_url)
|
||
logger.debug(f"Deleting tokens for '{server_url}' at '{token_path}'")
|
||
if token_path.exists():
|
||
token_path.unlink()
|
||
logger.debug(f"Token file '{token_path}' deleted.")
|
||
else:
|
||
logger.debug(f"Token file '{token_path}' not found, nothing to delete.")
|
||
|
||
|
||
class OAuth:
|
||
"""OAuth authentication handler for MCP clients.
|
||
|
||
This is the main class that handles all the authentication
|
||
It has several features:
|
||
|
||
- Discovers OAuth server capabilities automatically
|
||
- Registers client dynamically when possible
|
||
- Manages token storage and refresh automaticlly"""
|
||
|
||
def __init__(
|
||
self,
|
||
server_url: str,
|
||
token_storage: FileTokenStorage | None = None,
|
||
scope: str | None = None,
|
||
client_id: str | None = None,
|
||
client_secret: str | None = None,
|
||
callback_port: int | None = None,
|
||
oauth_provider: OAuthClientProvider | None = None,
|
||
client_metadata_url: str | None = None,
|
||
):
|
||
"""Initialize OAuth handler.
|
||
|
||
Args:
|
||
server_url: The MCP server URL
|
||
token_storage: Token storage implementation. Defaults to FileTokenStorage
|
||
scope: OAuth scopes to request
|
||
client_id: OAuth client ID. If not provided, will attempt dynamic registration
|
||
client_secret: OAuth client secret (for confidential clients)
|
||
callback_port: Port for local callback server, if empty, 8080 is used
|
||
oauth_provider: OAuth client provider to prevent metadata discovery
|
||
client_metadata_url: Field used to authenticate with CIMD
|
||
"""
|
||
logger.debug(f"Initializing OAuth for server: {urlparse(server_url).netloc}")
|
||
self.server_url = server_url
|
||
self.token_storage = token_storage or FileTokenStorage()
|
||
self.scope = scope
|
||
self.client_id = client_id
|
||
self.client_secret = client_secret
|
||
self.client_metadata_url = client_metadata_url
|
||
|
||
if callback_port:
|
||
self.callback_port = callback_port
|
||
logger.info(f"Using custom callback port {self.callback_port} provided in config")
|
||
else:
|
||
self.callback_port = 8080
|
||
logger.info(f"Using default callback port {self.callback_port}")
|
||
|
||
# Set the default redirect uri
|
||
self.redirect_uri = f"http://localhost:{self.callback_port}/callback"
|
||
self._oauth_provider = oauth_provider
|
||
self._metadata: ServerOAuthMetadata | None = None
|
||
self._resource_metadata: ProtectedResourceMetadata | None = None
|
||
|
||
if self._oauth_provider:
|
||
self._metadata = self._oauth_provider.oauth_metadata
|
||
logger.debug(f"Using OAuth provider {self._oauth_provider.id} with metadata")
|
||
|
||
self._client: AsyncOAuth2Client | None = None
|
||
self._bearer_auth: BearerAuth | None = None
|
||
logger.debug(f"OAuth initialized with scope='{self.scope}', has_client_id={self.client_id is not None}")
|
||
|
||
@telemetry("oauth_initialize")
|
||
async def initialize(self, client: httpx.AsyncClient) -> BearerAuth | None:
|
||
"""Initialize OAuth and return bearer auth if tokens exist."""
|
||
logger.debug(f"OAuth.initialize called for {self.server_url}")
|
||
# Try to load existing tokens
|
||
logger.debug("Attempting to load existing tokens")
|
||
token_data = await self.token_storage.load_tokens(self.server_url)
|
||
if token_data:
|
||
logger.debug("Found existing tokens, checking validity")
|
||
if self._is_token_valid(token_data):
|
||
logger.debug("Existing token is valid, creating BearerAuth")
|
||
self._bearer_auth = BearerAuth(token=SecretStr(token_data.access_token))
|
||
logger.debug("OAuth.initialize returning existing valid BearerAuth")
|
||
return self._bearer_auth
|
||
else:
|
||
logger.debug("Existing token is expired")
|
||
else:
|
||
logger.debug("No existing tokens found")
|
||
|
||
# Discover OAuth metadata
|
||
if not self._metadata:
|
||
logger.debug("No valid token, proceeding to discover OAuth metadata")
|
||
await self._discover_metadata(client)
|
||
else:
|
||
logger.debug("Using provided OAuth metadata, skipping discovery")
|
||
|
||
logger.debug("OAuth.initialize finished, no valid token available yet")
|
||
return None
|
||
|
||
@telemetry("oauth_authenticate")
|
||
async def authenticate(self) -> BearerAuth:
|
||
"""Perform OAuth authentication flow."""
|
||
logger.debug("OAuth.authenticate called")
|
||
if not self._metadata:
|
||
logger.error("OAuth.authenticate called before metadata was discovered.")
|
||
raise OAuthAuthenticationError("OAuth metadata not discovered")
|
||
|
||
# The port check should be done now. OAuth servers
|
||
# register client_id with also redirect_uri, so we
|
||
# have to ensure port is available before DCR
|
||
try:
|
||
import socket
|
||
|
||
sock = socket.socket()
|
||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||
sock.bind(("127.0.0.1", self.callback_port))
|
||
sock.close()
|
||
logger.debug(f"Using registered port {self.callback_port} for callback")
|
||
except (ValueError, OSError) as exception:
|
||
logger.error(f"The port {self.callback_port} is not available! Try using a different port!")
|
||
raise exception
|
||
|
||
# Check if code challenge exists with S256
|
||
if (
|
||
not self._metadata.code_challenge_methods_supported
|
||
or "S256" not in self._metadata.code_challenge_methods_supported
|
||
):
|
||
raise OAuthAuthenticationError("The auth must support code challenge S256. Can't complete auth without it.")
|
||
|
||
# Check if it supports CIMD
|
||
supports_cimd = (
|
||
self._metadata.client_id_metadata_document_supported
|
||
if self._metadata.client_id_metadata_document_supported
|
||
else False
|
||
)
|
||
|
||
client_id = self.client_id
|
||
client_secret = self.client_secret
|
||
|
||
# 1) CIMD path (preferred when configured as specified in MCP spec)
|
||
if self.client_metadata_url and supports_cimd:
|
||
logger.debug(f"Using Client ID Metadata Document (CIMD) as client_id: {self.client_metadata_url}")
|
||
client_id = self.client_metadata_url
|
||
client_secret = None # public client
|
||
else:
|
||
# 2) Legacy paths: pre-registered clients or DCR
|
||
registration = None # Track if we used DCR
|
||
if not client_id:
|
||
logger.debug("No client_id provided, attempting dynamic client registration")
|
||
# Try to load previously registered client
|
||
registration = await self._load_client_registration()
|
||
|
||
if registration:
|
||
logger.debug("Using previously registered client")
|
||
client_id = registration.client_id
|
||
client_secret = registration.client_secret
|
||
else:
|
||
# Attempt dynamic registration
|
||
registration = await self._try_dynamic_registration()
|
||
if registration:
|
||
logger.debug("Dynamic registration successful")
|
||
client_id = registration.client_id
|
||
client_secret = registration.client_secret
|
||
# Store for future use
|
||
await self._store_client_registration(registration)
|
||
else:
|
||
if supports_cimd and not self._metadata.registration_endpoint:
|
||
raise OAuthAuthenticationError(
|
||
"OAuth server only supports Client ID Metadata Documents. "
|
||
"Please provide 'client_metadata_url' in the auth configuration "
|
||
"pointing to your CIMD JSON document."
|
||
)
|
||
logger.error("Dynamic client registration failed or not supported")
|
||
raise OAuthAuthenticationError(
|
||
"OAuth requires a client_id. Server does not support dynamic registration. "
|
||
"Please provide one in the auth configuration. "
|
||
"Example: {'auth': {'client_id': 'your-registered-client-id'}}"
|
||
)
|
||
|
||
logger.debug(f"Using client_id: {client_id}")
|
||
|
||
# Generate PKCE code_verifier/challenge
|
||
code_verifier, code_challenge = self._generate_pkce_pair()
|
||
|
||
# Create OAuth client
|
||
logger.debug("Creating AsyncOAuth2Client")
|
||
self._client = AsyncOAuth2Client(
|
||
client_id=client_id,
|
||
client_secret=client_secret,
|
||
redirect_uri=self.redirect_uri,
|
||
scope=self.scope,
|
||
)
|
||
|
||
# Start callback server
|
||
logger.debug("Starting OAuth callback server")
|
||
|
||
callback_server = OAuthCallbackServer(port=self.callback_port)
|
||
redirect_uri = await callback_server.start()
|
||
self._client.redirect_uri = redirect_uri
|
||
logger.debug(f"Callback server started, redirect_uri: {redirect_uri}")
|
||
|
||
# Generate state for CSRF protection
|
||
state = secrets.token_urlsafe(32)
|
||
logger.debug(f"Generated state for CSRF protection: {state}")
|
||
|
||
# Get resource as required in the MCP spec
|
||
resource = self._resource_metadata.resource if self._resource_metadata else self.server_url
|
||
|
||
# Build authorization URL
|
||
logger.debug("Creating authorization URL")
|
||
auth_url, _ = self._client.create_authorization_url(
|
||
str(self._metadata.authorization_endpoint),
|
||
resource=resource,
|
||
state=state,
|
||
code_challenge=code_challenge,
|
||
code_challenge_method="S256",
|
||
)
|
||
|
||
logger.debug("OAuth flow started:")
|
||
logger.debug(f" Client ID: {client_id}")
|
||
logger.debug(f" Authorization endpoint: {self._metadata.authorization_endpoint}")
|
||
logger.debug(f" Redirect URI: {redirect_uri}")
|
||
logger.debug(f" Scope: {self.scope}")
|
||
|
||
# Open browser for authorization
|
||
print(f"Opening browser for authorization: {auth_url}")
|
||
webbrowser.open(auth_url)
|
||
|
||
# Wait for callback
|
||
logger.debug("Waiting for authorization code from callback server")
|
||
try:
|
||
response = await callback_server.wait_for_code()
|
||
logger.debug("Received response from callback server")
|
||
except TimeoutError as e:
|
||
logger.error(f"OAuth callback timed out: {e}")
|
||
raise OAuthAuthenticationError(f"OAuth timeout: {e}") from e
|
||
|
||
if response.error:
|
||
logger.error("OAuth authorization failed:")
|
||
logger.error(f" Error: {response.error}")
|
||
logger.error(f" Description: {response.error_description}")
|
||
logger.error(" The OAuth server returned this error, likely because:")
|
||
logger.error(f" 1. The client_id '{client_id}' is not registered with the OAuth server")
|
||
logger.error(" 2. The redirect_uri doesn't match the registered one")
|
||
logger.error(" 3. The requested scopes are invalid")
|
||
raise OAuthAuthenticationError(f"{response.error}: {response.error_description}")
|
||
|
||
if not response.code:
|
||
logger.error("Callback response did not contain an authorization code")
|
||
raise OAuthAuthenticationError("No authorization code received")
|
||
|
||
logger.debug(f"Received authorization code: {response.code[:10]}...")
|
||
|
||
# Verify state
|
||
logger.debug(f"Verifying state. Expected: {state}, Got: {response.state}")
|
||
if response.state != state:
|
||
logger.error("State mismatch in OAuth callback. Possible CSRF attack.")
|
||
raise OAuthAuthenticationError("Invalid state parameter - possible CSRF attack")
|
||
logger.debug("State verified successfully")
|
||
|
||
# Exchange code for tokens
|
||
logger.debug("Exchanging authorization code for tokens")
|
||
try:
|
||
token_response = await self._client.fetch_token(
|
||
str(self._metadata.token_endpoint),
|
||
authorization_response=f"{redirect_uri}?code={response.code}&state={response.state}",
|
||
grant_type="authorization_code",
|
||
code_verifier=code_verifier,
|
||
)
|
||
logger.debug("Successfully fetched tokens")
|
||
except OAuth2Error as e:
|
||
logger.error(f"Token exchange failed: {e}")
|
||
raise OAuthAuthenticationError(f"Token exchange failed: {e}") from e
|
||
|
||
# Save tokens
|
||
logger.debug("Saving fetched tokens")
|
||
await self.token_storage.save_tokens(self.server_url, token_response)
|
||
|
||
# Create bearer auth
|
||
logger.debug("Creating BearerAuth with new access token")
|
||
self._bearer_auth = BearerAuth(token=SecretStr(token_response["access_token"]))
|
||
return self._bearer_auth
|
||
|
||
def _generate_pkce_pair(self) -> tuple[str, str]:
|
||
"""Generate PKCE code_verifier and S256 code_challenge"""
|
||
code_verifier = secrets.token_urlsafe(64)
|
||
code_challenge = (
|
||
base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode("ascii")).digest())
|
||
.rstrip(b"=")
|
||
.decode("ascii")
|
||
)
|
||
|
||
return code_verifier, code_challenge
|
||
|
||
async def _discover_metadata(self, client: httpx.AsyncClient) -> None:
|
||
"""Discover OAuth metadata from server."""
|
||
logger.debug(f"Discovering OAuth metadata for {self.server_url}")
|
||
# Try well-known endpoint first
|
||
parsed = urlparse(self.server_url)
|
||
|
||
base_url = f"{parsed.scheme}://{parsed.netloc}"
|
||
prm_url: str | None = None
|
||
|
||
# 1) Try to get PRM URL from 401 + WWW-Authenticate
|
||
try:
|
||
init_resp = await client.get(self.server_url, headers={"Accept": "application/json"})
|
||
if init_resp.status_code == 401:
|
||
# Parse the resource_metadata
|
||
prm_url = self._extract_prm(init_resp)
|
||
except httpx.HTTPError as e:
|
||
logger.debug(f"Failed probing server for PRM via 401: {e}")
|
||
|
||
# 1b) If WWW-Authenticate didn’t give us a URL, try well-known PRM paths
|
||
if not prm_url:
|
||
path = (parsed.path or "").rstrip("/")
|
||
candidate_prm_urls: list[str] = []
|
||
|
||
# Path-specific form: /.well-known/oauth-protected-resource{path}
|
||
if path:
|
||
candidate_prm_urls.append(f"{base_url}/.well-known/oauth-protected-resource{path}")
|
||
|
||
# Root form: /.well-known/oauth-protected-resource
|
||
candidate_prm_urls.append(f"{base_url}/.well-known/oauth-protected-resource")
|
||
|
||
for candidate in candidate_prm_urls:
|
||
try:
|
||
logger.debug(f"Trying OAuth PRM endpoint at: {candidate}")
|
||
prm_response = await client.get(candidate)
|
||
prm_response.raise_for_status()
|
||
prm = prm_response.json()
|
||
self._resource_metadata = ProtectedResourceMetadata(**prm)
|
||
logger.debug("Successfully got the PRM data")
|
||
logger.debug(f"Authorization servers: {self._resource_metadata.authorization_servers}")
|
||
prm_url = candidate
|
||
break
|
||
except (httpx.HTTPError, ValueError) as e:
|
||
logger.debug(f"Failed to discover OAuth PRM at {candidate}: {e}")
|
||
|
||
# 2) If we have PRM URL but _resource_metadata is still None
|
||
if prm_url and not self._resource_metadata:
|
||
try:
|
||
logger.debug(f"Trying OAuth PRM endpoint at: {prm_url}")
|
||
prm_response = await client.get(prm_url)
|
||
prm_response.raise_for_status()
|
||
prm = prm_response.json()
|
||
self._resource_metadata = ProtectedResourceMetadata(**prm)
|
||
logger.debug("Successfully got the PRM data")
|
||
logger.debug(f"Authorization servers: {self._resource_metadata.authorization_servers}")
|
||
except (httpx.HTTPError, ValueError) as e:
|
||
logger.debug(f"Failed to discover OAuth PRM at {prm_url}: {e}")
|
||
pass
|
||
|
||
# 3) For each authorization server, try AS metadata and stop on first success
|
||
#
|
||
# Well-known URL construction differs between OAuth 2.0 and OpenID Connect:
|
||
# - OAuth 2.0 (RFC 8414): Insert .well-known between host and path
|
||
# - OpenID Connect: Append .well-known to issuer
|
||
#
|
||
# Example for issuer https://github.com/login/oauth:
|
||
# - OAuth 2.0: https://github.com/.well-known/oauth-authorization-server/login/oauth
|
||
# - OIDC: https://github.com/login/oauth/.well-known/openid-configuration
|
||
#
|
||
# See: https://www.rfc-editor.org/rfc/rfc8414.html#section-3.1
|
||
auth_servers = self._resource_metadata.authorization_servers if self._resource_metadata else []
|
||
for auth_server in auth_servers:
|
||
parsed_issuer = urlparse(auth_server)
|
||
issuer_base = f"{parsed_issuer.scheme}://{parsed_issuer.netloc}"
|
||
issuer_path = (parsed_issuer.path or "").rstrip("/")
|
||
|
||
# OAuth 2.0 (RFC 8414): Insert .well-known between host and path
|
||
oauth_candidates: list[str] = []
|
||
if issuer_path:
|
||
# e.g., https://github.com/.well-known/oauth-authorization-server/login/oauth
|
||
oauth_candidates.append(f"{issuer_base}/.well-known/oauth-authorization-server{issuer_path}")
|
||
else:
|
||
oauth_candidates.append(f"{issuer_base}/.well-known/oauth-authorization-server")
|
||
|
||
# OpenID Connect: Append .well-known to issuer
|
||
oidc_candidates: list[str] = []
|
||
# e.g., https://github.com/login/oauth/.well-known/openid-configuration
|
||
oidc_candidates.append(f"{auth_server.rstrip('/')}/.well-known/openid-configuration")
|
||
|
||
# Try OAuth 2.0 Authorization Server Metadata endpoints first
|
||
for well_known_url in oauth_candidates:
|
||
try:
|
||
logger.debug(f"Trying OAuth metadata discovery for issuer: {parsed_issuer.netloc}")
|
||
response = await client.get(well_known_url)
|
||
response.raise_for_status()
|
||
metadata = response.json()
|
||
self._metadata = ServerOAuthMetadata(**metadata)
|
||
logger.debug("Successfully discovered OAuth metadata")
|
||
logger.debug(f" Authorization endpoint: {self._metadata.authorization_endpoint}")
|
||
logger.debug(f" Token endpoint: {self._metadata.token_endpoint}")
|
||
return
|
||
except (httpx.HTTPError, ValueError) as e:
|
||
logger.debug(f"Failed to discover OAuth metadata: {e}")
|
||
|
||
# Then try OpenID Connect Discovery endpoints
|
||
for oidc_url in oidc_candidates:
|
||
logger.debug(f"Trying OpenID Connect discovery for issuer: {parsed_issuer.netloc}")
|
||
try:
|
||
response = await client.get(oidc_url)
|
||
response.raise_for_status()
|
||
metadata = response.json()
|
||
self._metadata = ServerOAuthMetadata(**metadata)
|
||
logger.debug("Successfully discovered OIDC metadata")
|
||
logger.debug(f" Authorization endpoint: {self._metadata.authorization_endpoint}")
|
||
logger.debug(f" Token endpoint: {self._metadata.token_endpoint}")
|
||
return
|
||
except (httpx.HTTPError, ValueError) as e:
|
||
logger.debug(f"Failed to discover OIDC metadata: {e}")
|
||
|
||
# 4) If PRM path didn't yield anything, fall back to old host-level discovery
|
||
if not self._metadata:
|
||
well_known_url = f"{base_url}/.well-known/oauth-authorization-server"
|
||
try:
|
||
logger.debug(f"Trying OAuth metadata discovery for host: {parsed.netloc}")
|
||
resp = await client.get(well_known_url)
|
||
resp.raise_for_status()
|
||
self._metadata = ServerOAuthMetadata(**resp.json())
|
||
logger.debug("Successfully discovered OAuth metadata (host-level fallback)")
|
||
return
|
||
except (httpx.HTTPError, ValueError) as e:
|
||
logger.debug(f"Failed to discover OAuth metadata: {e}")
|
||
|
||
oidc_url = f"{base_url}/.well-known/openid-configuration"
|
||
logger.debug(f"Trying OpenID Connect discovery for host: {parsed.netloc}")
|
||
try:
|
||
resp = await client.get(oidc_url)
|
||
resp.raise_for_status()
|
||
self._metadata = ServerOAuthMetadata(**resp.json())
|
||
logger.debug("Successfully discovered OIDC metadata (host-level fallback)")
|
||
return
|
||
except (httpx.HTTPError, ValueError) as e:
|
||
logger.debug(f"Failed to discover OIDC metadata: {e}")
|
||
|
||
logger.error(f"Failed to discover OAuth/OIDC metadata for {self.server_url}")
|
||
raise OAuthDiscoveryError(
|
||
f"Failed to discover OAuth metadata for {self.server_url}. "
|
||
"Server must support OAuth metadata discovery at "
|
||
"/.well-known/oauth-authorization-server or /.well-known/openid-configuration"
|
||
)
|
||
|
||
def _extract_prm(self, response: httpx.Response) -> str | None:
|
||
www_auth = response.headers.get("WWW-Authenticate")
|
||
if not www_auth:
|
||
return None
|
||
|
||
# If the server has a scope and the user didn't override it
|
||
scope_match = re.search(r'scope="([^"]+)"', www_auth)
|
||
if scope_match and not self.scope:
|
||
self.scope = scope_match.group(1)
|
||
logger.debug("Using scope from WWW-Authenticate header")
|
||
|
||
# Extract PRM url
|
||
match = re.search(r'resource_metadata="([^"]+)"', www_auth)
|
||
if match:
|
||
return match.group(1)
|
||
|
||
return None
|
||
|
||
def _is_token_valid(self, token_data: TokenData) -> bool:
|
||
"""Check if token is still valid."""
|
||
logger.debug("Checking token validity")
|
||
if not token_data.expires_at:
|
||
logger.debug("Token has no expiration time, assuming it's valid.")
|
||
return True # No expiration info, assume valid
|
||
|
||
# Check if token expires in more than 60 seconds
|
||
expires_at = datetime.fromtimestamp(token_data.expires_at, tz=UTC)
|
||
now = datetime.now(tz=UTC)
|
||
is_valid = expires_at > now + timedelta(seconds=60)
|
||
logger.debug(f"Token expires at {expires_at}, current time is {now}. Valid: {is_valid}")
|
||
return is_valid
|
||
|
||
async def _try_dynamic_registration(self) -> ClientRegistrationResponse | None:
|
||
"""Try Dynamic Client Registration if supported by the server."""
|
||
if not self._metadata or not self._metadata.registration_endpoint:
|
||
logger.debug("No registration endpoint available, skipping DCR")
|
||
return None
|
||
|
||
logger.info("Attempting Dynamic Client Registration")
|
||
logger.debug(f"DCR endpoint: {self._metadata.registration_endpoint}")
|
||
|
||
registration_data = {
|
||
"client_name": "mcp-use",
|
||
"redirect_uris": [self.redirect_uri],
|
||
"grant_types": ["authorization_code"],
|
||
"response_types": ["code"],
|
||
"token_endpoint_auth_method": "none", # Public client
|
||
"application_type": "native",
|
||
}
|
||
|
||
# Add scope if specified
|
||
if self.scope:
|
||
registration_data["scope"] = self.scope
|
||
|
||
logger.debug(f"DCR request payload: {registration_data}")
|
||
try:
|
||
async with httpx.AsyncClient() as client:
|
||
response = await client.post(
|
||
str(self._metadata.registration_endpoint),
|
||
json=registration_data,
|
||
headers={"Content-Type": "application/json"},
|
||
)
|
||
logger.debug(f"DCR response status: {response.status_code}")
|
||
response.raise_for_status()
|
||
|
||
# Parse registration response
|
||
reg_response_data = response.json()
|
||
logger.debug(f"DCR response body: {reg_response_data}")
|
||
reg_response = ClientRegistrationResponse(**reg_response_data)
|
||
|
||
# Update our credentials
|
||
self.client_id = reg_response.client_id
|
||
self.client_secret = reg_response.client_secret
|
||
|
||
logger.info(f"Dynamic Client Registration successful: {self.client_id}")
|
||
|
||
# Store the registered client info for future use
|
||
await self._store_client_registration(reg_response)
|
||
|
||
return reg_response
|
||
|
||
except httpx.HTTPError as e:
|
||
logger.warning(f"Dynamic Client Registration failed: {e}")
|
||
# Log the response if available
|
||
if hasattr(e, "response") and e.response:
|
||
logger.debug(f"DCR response: {e.response.status_code} - {e.response.text}")
|
||
return None
|
||
except Exception as e:
|
||
logger.warning(f"Unexpected error during DCR: {e}")
|
||
return None
|
||
|
||
async def _store_client_registration(self, registration: ClientRegistrationResponse) -> None:
|
||
"""Store client registration data for future use."""
|
||
logger.debug("Storing client registration data")
|
||
# Store alongside tokens in a separate file
|
||
storage_path = self.token_storage.base_dir / "registrations"
|
||
storage_path.mkdir(parents=True, exist_ok=True)
|
||
|
||
# Create a safe filename from the server URL
|
||
parsed = urlparse(self.server_url)
|
||
filename = f"{parsed.netloc}_{parsed.path.replace('/', '_')}_registration.json"
|
||
reg_path = storage_path / filename
|
||
logger.debug(f"Storing client registration to '{reg_path}'")
|
||
|
||
# Store registration data
|
||
reg_path.write_text(registration.model_dump_json())
|
||
logger.debug("Client registration data stored successfully")
|
||
|
||
async def _load_client_registration(self) -> ClientRegistrationResponse | None:
|
||
"""Load previously registered client credentials if available."""
|
||
logger.debug("Attempting to load client registration data")
|
||
storage_path = self.token_storage.base_dir / "registrations"
|
||
|
||
# Create a safe filename from the server URL
|
||
parsed = urlparse(self.server_url)
|
||
filename = f"{parsed.netloc}_{parsed.path.replace('/', '_')}_registration.json"
|
||
reg_path = storage_path / filename
|
||
logger.debug(f"Checking for client registration file at '{reg_path}'")
|
||
|
||
if reg_path.exists():
|
||
logger.debug("Client registration file found")
|
||
try:
|
||
data = json.loads(reg_path.read_text())
|
||
reg_response = ClientRegistrationResponse(**data)
|
||
|
||
# Check if registration is still valid (if expiry info provided)
|
||
if reg_response.client_secret_expires_at:
|
||
expires_at = datetime.fromtimestamp(reg_response.client_secret_expires_at, tz=UTC)
|
||
now = datetime.now(tz=UTC)
|
||
logger.debug(f"Checking client registration expiry. Expires at: {expires_at}, Now: {now}")
|
||
if expires_at <= now:
|
||
logger.debug("Stored client registration has expired")
|
||
return None
|
||
|
||
self.client_id = reg_response.client_id
|
||
self.client_secret = reg_response.client_secret
|
||
logger.debug(f"Loaded stored client registration: {self.client_id}")
|
||
return reg_response
|
||
|
||
except Exception as e:
|
||
logger.debug(f"Failed to load client registration: {e}")
|
||
else:
|
||
logger.debug("Client registration file not found")
|
||
|
||
return None
|
||
|
||
@telemetry("oauth_refresh_token")
|
||
async def refresh_token(self) -> BearerAuth | None:
|
||
"""Refresh the access token if possible."""
|
||
logger.debug("Attempting to refresh token")
|
||
token_data = await self.token_storage.load_tokens(self.server_url)
|
||
if not token_data or not token_data.refresh_token:
|
||
logger.debug("No token data or refresh token found, cannot refresh.")
|
||
return None
|
||
|
||
if not self._metadata:
|
||
logger.debug("No OAuth metadata available, cannot refresh token.")
|
||
return None
|
||
|
||
if not self._client:
|
||
if not self.client_id:
|
||
logger.debug("Cannot refresh token without client_id")
|
||
return None
|
||
logger.debug("Creating temporary AsyncOAuth2Client for token refresh")
|
||
self._client = AsyncOAuth2Client(client_id=self.client_id, client_secret=self.client_secret)
|
||
|
||
logger.debug("Calling client.refresh_token")
|
||
try:
|
||
token_response = await self._client.refresh_token(
|
||
str(self._metadata.token_endpoint),
|
||
refresh_token=token_data.refresh_token,
|
||
)
|
||
logger.debug("Token refresh successful")
|
||
|
||
# Save new tokens
|
||
logger.debug("Saving new tokens after refresh")
|
||
await self.token_storage.save_tokens(self.server_url, token_response)
|
||
|
||
# Update bearer auth
|
||
logger.debug("Updating BearerAuth with new access token")
|
||
self._bearer_auth = BearerAuth(token=SecretStr(token_response["access_token"]))
|
||
return self._bearer_auth
|
||
|
||
except OAuth2Error as e:
|
||
logger.warning(f"Token refresh failed: {e}. Re-authentication is required.")
|
||
# Refresh failed, need to re-authenticate
|
||
return None
|