From dd86edf27567e58abf355e23ae1999a641a9afcb Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 20 Oct 2025 15:41:16 -0400 Subject: [PATCH] Rename OAuthProxy -> OAuthDCRProxy --- .../fastmcp-server-auth-oauth_proxy.mdx | 2 +- src/fastmcp/server/auth/__init__.py | 20 +- src/fastmcp/server/auth/oauth_dcr_proxy.py | 2022 ++++++++++++++++ src/fastmcp/server/auth/oauth_proxy.py | 2032 +---------------- src/fastmcp/server/auth/oidc_proxy.py | 4 +- src/fastmcp/server/auth/providers/azure.py | 4 +- src/fastmcp/server/auth/providers/github.py | 4 +- src/fastmcp/server/auth/providers/google.py | 4 +- src/fastmcp/server/auth/providers/workos.py | 4 +- .../auth/test_github_provider_integration.py | 2 +- tests/server/auth/oauth_dcr_proxy/__init__.py | 0 .../test_oauth_consent_flow.py | 22 +- .../auth/oauth_dcr_proxy/test_oauth_proxy.py | 1297 +++++++++++ .../test_oauth_proxy_redirect_validation.py | 12 +- .../test_oauth_proxy_storage.py | 8 +- .../{ => oauth_dcr_proxy}/test_oidc_proxy.py | 0 tests/server/auth/test_oauth_proxy.py | 54 +- 17 files changed, 3416 insertions(+), 2075 deletions(-) create mode 100644 src/fastmcp/server/auth/oauth_dcr_proxy.py create mode 100644 tests/server/auth/oauth_dcr_proxy/__init__.py rename tests/server/auth/{ => oauth_dcr_proxy}/test_oauth_consent_flow.py (98%) create mode 100644 tests/server/auth/oauth_dcr_proxy/test_oauth_proxy.py rename tests/server/auth/{ => oauth_dcr_proxy}/test_oauth_proxy_redirect_validation.py (97%) rename tests/server/auth/{ => oauth_dcr_proxy}/test_oauth_proxy_storage.py (97%) rename tests/server/auth/{ => oauth_dcr_proxy}/test_oidc_proxy.py (100%) diff --git a/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx index 0877bcad2..a2fba81e9 100644 --- a/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx @@ -3,7 +3,7 @@ title: oauth_proxy sidebarTitle: oauth_proxy --- -# `fastmcp.server.auth.oauth_proxy` +# `fastmcp.server.auth.oauth_dcr_proxy` OAuth Proxy Provider for FastMCP. diff --git a/src/fastmcp/server/auth/__init__.py b/src/fastmcp/server/auth/__init__.py index e7111ec97..1e26d0a44 100644 --- a/src/fastmcp/server/auth/__init__.py +++ b/src/fastmcp/server/auth/__init__.py @@ -6,8 +6,10 @@ from .auth import ( AuthProvider, ) from .providers.jwt import JWTVerifier, StaticTokenVerifier -from .oauth_proxy import OAuthProxy +from .oauth_dcr_proxy import OAuthDCRProxy +import warnings +import fastmcp __all__ = [ "AuthProvider", @@ -17,7 +19,7 @@ __all__ = [ "StaticTokenVerifier", "RemoteAuthProvider", "AccessToken", - "OAuthProxy", + "OAuthDCRProxy", ] @@ -27,4 +29,18 @@ def __getattr__(name: str): from .providers.bearer import BearerAuthProvider return BearerAuthProvider + + if name == "OAuthProxy": + from .oauth_dcr_proxy import OAuthDCRProxy as OAuthProxy + + if fastmcp.settings.deprecation_warnings: + warnings.warn( + "The `OAuthProxy` class is deprecated " + "and has been replaced by `OAuthDCRProxy`. " + "This import will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) + return OAuthProxy + raise AttributeError(f"module '{__name__}' has no attribute '{name}'") diff --git a/src/fastmcp/server/auth/oauth_dcr_proxy.py b/src/fastmcp/server/auth/oauth_dcr_proxy.py new file mode 100644 index 000000000..7ce3b3e50 --- /dev/null +++ b/src/fastmcp/server/auth/oauth_dcr_proxy.py @@ -0,0 +1,2022 @@ +"""OAuth Proxy Provider for FastMCP. + +This provider acts as a transparent proxy to an upstream OAuth Authorization Server, +handling Dynamic Client Registration locally while forwarding all other OAuth flows. +This enables authentication with upstream providers that don't support DCR or have +restricted client registration policies. + +Key features: +- Proxies authorization and token endpoints to upstream server +- Implements local Dynamic Client Registration with fixed upstream credentials +- Validates tokens using upstream JWKS +- Maintains minimal local state for bookkeeping +- Enhanced logging with request correlation + +This implementation is based on the OAuth 2.1 specification and is designed for +production use with enterprise identity providers. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import secrets +import time +from base64 import urlsafe_b64encode +from typing import TYPE_CHECKING, Any, Final +from urllib.parse import urlencode, urlparse + +import httpx +from authlib.common.security import generate_token +from authlib.integrations.httpx_client import AsyncOAuth2Client +from key_value.aio.adapters.pydantic import PydanticAdapter +from key_value.aio.protocols import AsyncKeyValue +from key_value.aio.stores.memory import MemoryStore +from mcp.server.auth.handlers.token import TokenErrorResponse, TokenSuccessResponse +from mcp.server.auth.handlers.token import TokenHandler as _SDKTokenHandler +from mcp.server.auth.json_response import PydanticJSONResponse +from mcp.server.auth.middleware.client_auth import ClientAuthenticator +from mcp.server.auth.provider import ( + AccessToken, + AuthorizationCode, + AuthorizationParams, + RefreshToken, + TokenError, +) +from mcp.server.auth.routes import cors_middleware +from mcp.server.auth.settings import ( + ClientRegistrationOptions, + RevocationOptions, +) +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken +from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, SecretStr +from starlette.requests import Request +from starlette.responses import HTMLResponse, RedirectResponse +from starlette.routing import Route + +from fastmcp.server.auth.auth import OAuthProvider, TokenVerifier +from fastmcp.server.auth.jwt_issuer import ( + JWTIssuer, + TokenEncryption, +) +from fastmcp.server.auth.redirect_validation import ( + validate_redirect_uri, +) +from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.ui import ( + BUTTON_STYLES, + DETAIL_BOX_STYLES, + INFO_BOX_STYLES, + TOOLTIP_STYLES, + create_detail_box, + create_logo, + create_page, + create_secure_html_response, +) + +if TYPE_CHECKING: + pass + +logger = get_logger(__name__) + + +# ------------------------------------------------------------------------- +# Constants +# ------------------------------------------------------------------------- + +# Default token expiration times +DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS: Final[int] = 60 * 60 # 1 hour +DEFAULT_AUTH_CODE_EXPIRY_SECONDS: Final[int] = 5 * 60 # 5 minutes + +# HTTP client timeout +HTTP_TIMEOUT_SECONDS: Final[int] = 30 + + +# ------------------------------------------------------------------------- +# Pydantic Models +# ------------------------------------------------------------------------- + + +class OAuthTransaction(BaseModel): + """OAuth transaction state for consent flow. + + Stored server-side to track active authorization flows with client context. + Includes CSRF tokens for consent protection per MCP security best practices. + """ + + txn_id: str + client_id: str + client_redirect_uri: str + client_state: str + code_challenge: str | None + code_challenge_method: str + scopes: list[str] + created_at: float + resource: str | None = None + proxy_code_verifier: str | None = None + csrf_token: str | None = None + csrf_expires_at: float | None = None + + +class ClientCode(BaseModel): + """Client authorization code with PKCE and upstream tokens. + + Stored server-side after upstream IdP callback. Contains the upstream + tokens bound to the client's PKCE challenge for secure token exchange. + """ + + code: str + client_id: str + redirect_uri: str + code_challenge: str | None + code_challenge_method: str + scopes: list[str] + idp_tokens: dict[str, Any] + expires_at: float + created_at: float + + +class UpstreamTokenSet(BaseModel): + """Stored upstream OAuth tokens from identity provider. + + These tokens are obtained from the upstream provider (Google, GitHub, etc.) + and are stored encrypted at rest. They are never exposed to MCP clients. + """ + + upstream_token_id: str # Unique ID for this token set + access_token: bytes # Encrypted upstream access token + refresh_token: bytes | None # Encrypted upstream refresh token + refresh_token_expires_at: ( + float | None + ) # Unix timestamp when refresh token expires (if known) + expires_at: float # Unix timestamp when access token expires + token_type: str # Usually "Bearer" + scope: str # Space-separated scopes + client_id: str # MCP client this is bound to + created_at: float # Unix timestamp + raw_token_data: dict[str, Any] = Field(default_factory=dict) # Full token response + + +class JTIMapping(BaseModel): + """Maps FastMCP token JTI to upstream token ID. + + This allows stateless JWT validation while still being able to look up + the corresponding upstream token when tools need to access upstream APIs. + """ + + jti: str # JWT ID from FastMCP-issued token + upstream_token_id: str # References UpstreamTokenSet + created_at: float # Unix timestamp + + +class ProxyDCRClient(OAuthClientInformationFull): + """Client for DCR proxy with configurable redirect URI validation. + + This special client class is critical for the OAuth proxy to work correctly + with Dynamic Client Registration (DCR). Here's why it exists: + + Problem: + -------- + When MCP clients use OAuth, they dynamically register with random localhost + ports (e.g., http://localhost:55454/callback). The OAuth proxy needs to: + 1. Accept these dynamic redirect URIs from clients based on configured patterns + 2. Use its own fixed redirect URI with the upstream provider (Google, GitHub, etc.) + 3. Forward the authorization code back to the client's dynamic URI + + Solution: + --------- + This class validates redirect URIs against configurable patterns, + while the proxy internally uses its own fixed redirect URI with the upstream + provider. This allows the flow to work even when clients reconnect with + different ports or when tokens are cached. + + Without proper validation, clients could get "Redirect URI not registered" errors + when trying to authenticate with cached tokens, or security vulnerabilities could + arise from accepting arbitrary redirect URIs. + """ + + allowed_redirect_uri_patterns: list[str] | None = Field(default=None) + client_name: str | None = Field(default=None) + + def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl: + """Validate redirect URI against allowed patterns. + + Since we're acting as a proxy and clients register dynamically, + we validate their redirect URIs against configurable patterns. + This is essential for cached token scenarios where the client may + reconnect with a different port. + """ + if redirect_uri is not None: + # Validate against allowed patterns + if validate_redirect_uri( + redirect_uri=redirect_uri, + allowed_patterns=self.allowed_redirect_uri_patterns, + ): + return redirect_uri + # Fall back to normal validation if not in allowed patterns + return super().validate_redirect_uri(redirect_uri) + # If no redirect_uri provided, use default behavior + return super().validate_redirect_uri(redirect_uri) + + +# ------------------------------------------------------------------------- +# Helper Functions +# ------------------------------------------------------------------------- + + +def create_consent_html( + client_id: str, + redirect_uri: str, + scopes: list[str], + txn_id: str, + csrf_token: str, + client_name: str | None = None, + title: str = "Authorization Consent", + server_name: str | None = None, + server_icon_url: str | None = None, + server_website_url: str | None = None, +) -> str: + """Create a styled HTML consent page for OAuth authorization requests.""" + # Format scopes for display + scopes_display = ", ".join(scopes) if scopes else "None" + + # Build warning box with client name if available + import html as html_module + + client_display = html_module.escape(client_name or client_id) + server_name_escaped = html_module.escape(server_name or "FastMCP") + + # Make server name a hyperlink if website URL is available + if server_website_url: + website_url_escaped = html_module.escape(server_website_url) + server_display = f'{server_name_escaped}' + else: + server_display = server_name_escaped + + warning_box = f""" +
+

{client_display} is requesting access to {server_display}.

+

Review the details below before approving.

+
+ """ + + # Build detail box with client information + detail_rows = [] + if client_name: + detail_rows.append(("Client Name", client_name)) + detail_rows.extend( + [ + ("Client ID", client_id), + ("Redirect URI", redirect_uri), + ("Requested Scopes", scopes_display), + ] + ) + detail_box = create_detail_box(detail_rows) + + # Build form with buttons + form = f""" +
+ + +
+ + +
+
+ """ + + # Build help link with tooltip + help_link = """ + + """ + + # Build the page content + content = f""" +
+ {create_logo(icon_url=server_icon_url, alt_text=server_name or "FastMCP")} +

Authorization Consent

+ {warning_box} + {detail_box} + {form} +
+ {help_link} + """ + + # Additional styles needed for this page + additional_styles = ( + INFO_BOX_STYLES + DETAIL_BOX_STYLES + BUTTON_STYLES + TOOLTIP_STYLES + ) + + # Need to allow form-action for form submission + csp_policy = "default-src 'none'; style-src 'unsafe-inline'; img-src https:; base-uri 'none'; form-action *" + + return create_page( + content=content, + title=title, + additional_styles=additional_styles, + csp_policy=csp_policy, + ) + + +# ------------------------------------------------------------------------- +# Handler Classes +# ------------------------------------------------------------------------- + + +class TokenHandler(_SDKTokenHandler): + """TokenHandler that returns OAuth 2.1 compliant error responses. + + The MCP SDK always returns HTTP 400 for all client authentication issues. + However, OAuth 2.1 Section 5.3 and the MCP specification require that + invalid or expired tokens MUST receive a HTTP 401 response. + + This handler extends the base MCP SDK TokenHandler to transform client + authentication failures into OAuth 2.1 compliant responses: + - Changes 'unauthorized_client' to 'invalid_client' error code + - Returns HTTP 401 status code instead of 400 for client auth failures + + Per OAuth 2.1 Section 5.3: "The authorization server MAY return an HTTP 401 + (Unauthorized) status code to indicate which HTTP authentication schemes + are supported." + + Per MCP spec: "Invalid or expired tokens MUST receive a HTTP 401 response." + """ + + def response(self, obj: TokenSuccessResponse | TokenErrorResponse): + """Override response method to provide OAuth 2.1 compliant error handling.""" + # Check if this is a client authentication failure (not just unauthorized for grant type) + # unauthorized_client can mean two things: + # 1. Client authentication failed (client_id not found or wrong credentials) -> invalid_client 401 + # 2. Client not authorized for this grant type -> unauthorized_client 400 (correct per spec) + if ( + isinstance(obj, TokenErrorResponse) + and obj.error == "unauthorized_client" + and obj.error_description + and "Invalid client_id" in obj.error_description + ): + # Transform client auth failure to OAuth 2.1 compliant response + return PydanticJSONResponse( + content=TokenErrorResponse( + error="invalid_client", + error_description=obj.error_description, + error_uri=obj.error_uri, + ), + status_code=401, + headers={ + "Cache-Control": "no-store", + "Pragma": "no-cache", + }, + ) + + # Otherwise use default behavior from parent class + return super().response(obj) + + +class OAuthDCRProxy(OAuthProvider): + """OAuth provider that presents a DCR-compliant interface while proxying to non-DCR IDPs. + + Purpose + ------- + MCP clients expect OAuth providers to support Dynamic Client Registration (DCR), + where clients can register themselves dynamically and receive unique credentials. + Most enterprise IDPs (Google, GitHub, Azure AD, etc.) don't support DCR and require + pre-registered OAuth applications with fixed credentials. + + This proxy bridges that gap by: + - Presenting a full DCR-compliant OAuth interface to MCP clients + - Translating DCR registration requests to use pre-configured upstream credentials + - Proxying all OAuth flows to the upstream IDP with appropriate translations + - Managing the state and security requirements of both protocols + + Architecture Overview + -------------------- + The proxy maintains a single OAuth app registration with the upstream provider + while allowing unlimited MCP clients to register and authenticate dynamically. + It implements the complete OAuth 2.1 + DCR specification for clients while + translating to whatever OAuth variant the upstream provider requires. + + Key Translation Challenges Solved + --------------------------------- + 1. Dynamic Client Registration: + - MCP clients expect to register dynamically and get unique credentials + - Upstream IDPs require pre-registered apps with fixed credentials + - Solution: Accept DCR requests, return shared upstream credentials + + 2. Dynamic Redirect URIs: + - MCP clients use random localhost ports that change between sessions + - Upstream IDPs require fixed, pre-registered redirect URIs + - Solution: Use proxy's fixed callback URL with upstream, forward to client's dynamic URI + + 3. Authorization Code Mapping: + - Upstream returns codes for the proxy's redirect URI + - Clients expect codes for their own redirect URIs + - Solution: Exchange upstream code server-side, issue new code to client + + 4. State Parameter Collision: + - Both client and proxy need to maintain state through the flow + - Only one state parameter available in OAuth + - Solution: Use transaction ID as state with upstream, preserve client's state + + 5. Token Management: + - Clients may expect different token formats/claims than upstream provides + - Need to track tokens for revocation and refresh + - Solution: Store token relationships, forward upstream tokens transparently + + OAuth Flow Implementation + ------------------------ + 1. Client Registration (DCR): + - Accept any client registration request + - Store ProxyDCRClient that accepts dynamic redirect URIs + + 2. Authorization: + - Store transaction mapping client details to proxy flow + - Redirect to upstream with proxy's fixed redirect URI + - Use transaction ID as state parameter with upstream + + 3. Upstream Callback: + - Exchange upstream authorization code for tokens (server-side) + - Generate new authorization code bound to client's PKCE challenge + - Redirect to client's original dynamic redirect URI + + 4. Token Exchange: + - Validate client's code and PKCE verifier + - Return previously obtained upstream tokens + - Clean up one-time use authorization code + + 5. Token Refresh: + - Forward refresh requests to upstream using authlib + - Handle token rotation if upstream issues new refresh token + - Update local token mappings + + State Management + --------------- + The proxy maintains minimal but crucial state: + - _oauth_transactions: Active authorization flows with client context + - _client_codes: Authorization codes with PKCE challenges and upstream tokens + - _access_tokens, _refresh_tokens: Token storage for revocation + - Token relationship mappings for cleanup and rotation + + Security Considerations + ---------------------- + - PKCE enforced end-to-end (client to proxy, proxy to upstream) + - Authorization codes are single-use with short expiry + - Transaction IDs are cryptographically random + - All state is cleaned up after use to prevent replay + - Token validation delegates to upstream provider + + Provider Compatibility + --------------------- + Works with any OAuth 2.0 provider that supports: + - Authorization code flow + - Fixed redirect URI (configured in provider's app settings) + - Standard token endpoint + + Handles provider-specific requirements: + - Google: Ensures minimum scope requirements + - GitHub: Compatible with OAuth Apps and GitHub Apps + - Azure AD: Handles tenant-specific endpoints + - Generic: Works with any spec-compliant provider + """ + + def __init__( + self, + *, + # Upstream server configuration + upstream_authorization_endpoint: str, + upstream_token_endpoint: str, + upstream_client_id: str, + upstream_client_secret: str, + upstream_revocation_endpoint: str | None = None, + # Token validation + token_verifier: TokenVerifier, + # FastMCP server configuration + base_url: AnyHttpUrl | str, + redirect_path: str | None = None, + issuer_url: AnyHttpUrl | str | None = None, + service_documentation_url: AnyHttpUrl | str | None = None, + # Client redirect URI validation + allowed_client_redirect_uris: list[str] | None = None, + valid_scopes: list[str] | None = None, + # PKCE configuration + forward_pkce: bool = True, + # Token endpoint authentication + token_endpoint_auth_method: str | None = None, + # Extra parameters to forward to authorization endpoint + extra_authorize_params: dict[str, str] | None = None, + # Extra parameters to forward to token endpoint + extra_token_params: dict[str, str] | None = None, + # Client storage + client_storage: AsyncKeyValue | None = None, + # JWT signing key (optional, ephemeral if not provided) + jwt_signing_key: str | bytes | None = None, + # Token encryption key (optional, ephemeral if not provided) + token_encryption_key: str | bytes | None = None, + ): + """Initialize the OAuth proxy provider. + + Args: + upstream_authorization_endpoint: URL of upstream authorization endpoint + upstream_token_endpoint: URL of upstream token endpoint + upstream_client_id: Client ID registered with upstream server + upstream_client_secret: Client secret for upstream server + upstream_revocation_endpoint: Optional upstream revocation endpoint + token_verifier: Token verifier for validating access tokens + base_url: Public URL of the server that exposes this FastMCP server; redirect path is + relative to this URL + redirect_path: Redirect path configured in upstream OAuth app (defaults to "/auth/callback") + issuer_url: Issuer URL for OAuth metadata (defaults to base_url) + service_documentation_url: Optional service documentation URL + allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. + Patterns support wildcards (e.g., "http://localhost:*", "https://*.example.com/*"). + If None (default), only localhost redirect URIs are allowed. + If empty list, all redirect URIs are allowed (not recommended for production). + These are for MCP clients performing loopback redirects, NOT for the upstream OAuth app. + valid_scopes: List of all the possible valid scopes for a client. + These are advertised to clients through the `/.well-known` endpoints. Defaults to `required_scopes` if not provided. + forward_pkce: Whether to forward PKCE to upstream server (default True). + Enable for providers that support/require PKCE (Google, Azure, AWS, etc.). + Disable only if upstream provider doesn't support PKCE. + token_endpoint_auth_method: Token endpoint authentication method for upstream server. + Common values: "client_secret_basic", "client_secret_post", "none". + If None, authlib will use its default (typically "client_secret_basic"). + extra_authorize_params: Additional parameters to forward to the upstream authorization endpoint. + Useful for provider-specific parameters like Auth0's "audience". + Example: {"audience": "https://api.example.com"} + extra_token_params: Additional parameters to forward to the upstream token endpoint. + Useful for provider-specific parameters during token exchange. + client_storage: An AsyncKeyValue-compatible store for client registrations, registrations are stored in memory if not provided + jwt_signing_key: Optional secret for signing FastMCP JWT tokens (accepts any string or bytes). + Default: ephemeral (random salt at startup, won't survive restart). + Production: provide explicit key from environment variable. + token_encryption_key: Optional secret for encrypting upstream tokens at rest (accepts any string or bytes). + Default: ephemeral (random salt at startup, won't survive restart). + Production: provide explicit key from environment variable. + """ + # Always enable DCR since we implement it locally for MCP clients + client_registration_options = ClientRegistrationOptions( + enabled=True, + valid_scopes=valid_scopes or token_verifier.required_scopes, + ) + + # Enable revocation only if upstream endpoint provided + revocation_options = ( + RevocationOptions(enabled=True) if upstream_revocation_endpoint else None + ) + + super().__init__( + base_url=base_url, + issuer_url=issuer_url, + service_documentation_url=service_documentation_url, + client_registration_options=client_registration_options, + revocation_options=revocation_options, + required_scopes=token_verifier.required_scopes, + ) + + # Store upstream configuration + self._upstream_authorization_endpoint = upstream_authorization_endpoint + self._upstream_token_endpoint = upstream_token_endpoint + self._upstream_client_id = upstream_client_id + self._upstream_client_secret = SecretStr(upstream_client_secret) + self._upstream_revocation_endpoint = upstream_revocation_endpoint + self._default_scope_str = " ".join(self.required_scopes or []) + + # Store redirect configuration + if not redirect_path: + self._redirect_path = "/auth/callback" + else: + self._redirect_path = ( + redirect_path if redirect_path.startswith("/") else f"/{redirect_path}" + ) + # Redirect URI validation (consent flow provides primary protection) + if allowed_client_redirect_uris is None: + logger.info( + "allowed_client_redirect_uris not specified; accepting all redirect URIs. " + "Consent flow provides protection against confused deputy attacks. " + "Configure allowed patterns for defense-in-depth." + ) + self._allowed_client_redirect_uris = None + elif ( + isinstance(allowed_client_redirect_uris, list) + and not allowed_client_redirect_uris + ): + logger.warning( + "allowed_client_redirect_uris is empty list; no redirect URIs will be accepted. " + "This will block all OAuth clients." + ) + self._allowed_client_redirect_uris = [] + else: + self._allowed_client_redirect_uris = allowed_client_redirect_uris + + # PKCE configuration + self._forward_pkce = forward_pkce + + # Token endpoint authentication + self._token_endpoint_auth_method = token_endpoint_auth_method + + # Extra parameters for authorization and token endpoints + self._extra_authorize_params = extra_authorize_params or {} + self._extra_token_params = extra_token_params or {} + + self._client_storage: AsyncKeyValue = client_storage or MemoryStore() + + # Warn if using MemoryStore in production + if isinstance(client_storage, MemoryStore): + logger.warning( + "Using in-memory storage - all OAuth state (clients, tokens) will be lost on restart. " + "Additionally, without explicit jwt_signing_key and token_encryption_key, " + "keys are ephemeral and tokens won't survive restart even with persistent storage. " + "For production, configure persistent storage AND explicit keys." + ) + + # Cache HTTPS check to avoid repeated logging + self._is_https = str(self.base_url).startswith("https://") + if not self._is_https: + logger.warning( + "Using non-secure cookies for development; deploy with HTTPS for production." + ) + + self._client_store = PydanticAdapter[ProxyDCRClient]( + key_value=self._client_storage, + pydantic_model=ProxyDCRClient, + default_collection="mcp-oauth-proxy-clients", + raise_on_validation_error=True, + ) + + # OAuth transaction storage for IdP callback forwarding + # Reuse client_storage with different collections for state management + self._transaction_store = PydanticAdapter[OAuthTransaction]( + key_value=self._client_storage, + pydantic_model=OAuthTransaction, + default_collection="mcp-oauth-transactions", + raise_on_validation_error=True, + ) + + self._code_store = PydanticAdapter[ClientCode]( + key_value=self._client_storage, + pydantic_model=ClientCode, + default_collection="mcp-authorization-codes", + raise_on_validation_error=True, + ) + + # Storage for upstream tokens (encrypted at rest) + self._upstream_token_store = PydanticAdapter[UpstreamTokenSet]( + key_value=self._client_storage, + pydantic_model=UpstreamTokenSet, + default_collection="mcp-upstream-tokens", + raise_on_validation_error=True, + ) + + # Storage for JTI mappings (FastMCP token -> upstream token) + self._jti_mapping_store = PydanticAdapter[JTIMapping]( + key_value=self._client_storage, + pydantic_model=JTIMapping, + default_collection="mcp-jti-mappings", + raise_on_validation_error=True, + ) + + # JWT issuer and encryption (initialized lazily on first use) + self._custom_jwt_key = jwt_signing_key + self._custom_encryption_key = token_encryption_key + self._jwt_issuer: JWTIssuer | None = None + self._token_encryption: TokenEncryption | None = None + self._jwt_initialized = False + + # Local state for token bookkeeping only (no client caching) + self._access_tokens: dict[str, AccessToken] = {} + self._refresh_tokens: dict[str, RefreshToken] = {} + + # Token relation mappings for cleanup + self._access_to_refresh: dict[str, str] = {} + self._refresh_to_access: dict[str, str] = {} + + # Use the provided token validator + self._token_validator = token_verifier + + logger.debug( + "Initialized OAuth proxy provider with upstream server %s", + self._upstream_authorization_endpoint, + ) + + # ------------------------------------------------------------------------- + # PKCE Helper Methods + # ------------------------------------------------------------------------- + + def _generate_pkce_pair(self) -> tuple[str, str]: + """Generate PKCE code verifier and challenge pair. + + Returns: + Tuple of (code_verifier, code_challenge) using S256 method + """ + # Generate code verifier: 43-128 characters from unreserved set + code_verifier = generate_token(48) + + # Generate code challenge using S256 (SHA256 + base64url) + challenge_bytes = hashlib.sha256(code_verifier.encode()).digest() + code_challenge = urlsafe_b64encode(challenge_bytes).decode().rstrip("=") + + return code_verifier, code_challenge + + # ------------------------------------------------------------------------- + # JWT Token Factory Initialization + # ------------------------------------------------------------------------- + + async def _ensure_jwt_initialized(self) -> None: + """Initialize JWT issuer and token encryption (lazy initialization). + + Key derivation strategy: + - Default: Generate random salt at startup, derive ephemeral keys + → Keys change on restart, all tokens become invalid + → Perfect for development/testing where re-auth is acceptable + + - Production: User provides explicit keys via parameters + → Keys stable across restarts when combined with persistent storage + → Tokens survive restart, seamless client reconnection + """ + if self._jwt_initialized: + return + + # Generate random salt for this server instance (NOT persisted) + server_salt = secrets.token_urlsafe(32) + + # Derive or use custom JWT signing key + from fastmcp.server.auth.jwt_issuer import derive_key_from_secret + + if self._custom_jwt_key: + jwt_key = derive_key_from_secret( + secret=self._custom_jwt_key, + salt="fastmcp-jwt-signing-v1", + info=b"HS256", + ) + logger.info("Using explicit JWT signing key (will survive restarts)") + else: + # Ephemeral key from random salt + upstream secret + upstream_secret = self._upstream_client_secret.get_secret_value() + jwt_key = derive_key_from_secret( + secret=upstream_secret, + salt=f"fastmcp-jwt-signing-v1-{server_salt}", + info=b"HS256", + ) + logger.info( + "Using ephemeral JWT signing key - tokens will NOT survive server restart. " + "For production, provide explicit jwt_signing_key parameter." + ) + + # Initialize JWT issuer + issuer = str(self.base_url) + audience = f"{str(self.base_url).rstrip('/')}/mcp" + self._jwt_issuer = JWTIssuer( + issuer=issuer, + audience=audience, + signing_key=jwt_key, + ) + + # Derive or use custom encryption key + if self._custom_encryption_key: + encryption_key = derive_key_from_secret( + secret=self._custom_encryption_key, + salt="fastmcp-token-encryption-v1", + info=b"Fernet", + ) + # Fernet needs base64url-encoded key + encryption_key = base64.urlsafe_b64encode(encryption_key) + logger.info("Using explicit token encryption key (will survive restarts)") + else: + # Ephemeral key from random salt + upstream secret + upstream_secret = self._upstream_client_secret.get_secret_value() + key_material = derive_key_from_secret( + secret=upstream_secret, + salt=f"fastmcp-token-encryption-v1-{server_salt}", + info=b"Fernet", + ) + encryption_key = base64.urlsafe_b64encode(key_material) + logger.info( + "Using ephemeral token encryption key - encrypted tokens will NOT survive server restart. " + "For production, provide explicit token_encryption_key parameter." + ) + + self._token_encryption = TokenEncryption(encryption_key) + self._jwt_initialized = True + + # ------------------------------------------------------------------------- + # Client Registration (Local Implementation) + # ------------------------------------------------------------------------- + + async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: + """Get client information by ID. This is generally the random ID + provided to the DCR client during registration, not the upstream client ID. + + For unregistered clients, returns None (which will raise an error in the SDK). + """ + # Load from storage + if not (client := await self._client_store.get(key=client_id)): + return None + + if client.allowed_redirect_uri_patterns is None: + client.allowed_redirect_uri_patterns = self._allowed_client_redirect_uris + + return client + + async def register_client(self, client_info: OAuthClientInformationFull) -> None: + """Register a client locally + + When a client registers, we create a ProxyDCRClient that is more + forgiving about validating redirect URIs, since the DCR client's + redirect URI will likely be localhost or unknown to the proxied IDP. The + proxied IDP only knows about this server's fixed redirect URI. + """ + + # Create a ProxyDCRClient with configured redirect URI validation + proxy_client: ProxyDCRClient = ProxyDCRClient( + client_id=client_info.client_id, + client_secret=client_info.client_secret, + redirect_uris=client_info.redirect_uris or [AnyUrl("http://localhost")], + grant_types=client_info.grant_types + or ["authorization_code", "refresh_token"], + scope=client_info.scope or self._default_scope_str, + token_endpoint_auth_method="none", + allowed_redirect_uri_patterns=self._allowed_client_redirect_uris, + client_name=getattr(client_info, "client_name", None), + ) + + await self._client_store.put( + key=client_info.client_id, + value=proxy_client, + ) + + # Log redirect URIs to help users discover what patterns they might need + if client_info.redirect_uris: + for uri in client_info.redirect_uris: + logger.debug( + "Client registered with redirect_uri: %s - if restricting redirect URIs, " + "ensure this pattern is allowed in allowed_client_redirect_uris", + uri, + ) + + logger.debug( + "Registered client %s with %d redirect URIs", + client_info.client_id, + len(proxy_client.redirect_uris), + ) + + # ------------------------------------------------------------------------- + # Authorization Flow (Proxy to Upstream) + # ------------------------------------------------------------------------- + + async def authorize( + self, + client: OAuthClientInformationFull, + params: AuthorizationParams, + ) -> str: + """Start OAuth transaction and route through consent interstitial. + + Flow: + 1. Store transaction with client details and PKCE (if forwarding) + 2. Return local /consent URL; browser visits consent first + 3. Consent handler redirects to upstream IdP if approved/already approved + """ + # Generate transaction ID for this authorization request + txn_id = secrets.token_urlsafe(32) + + # Generate proxy's own PKCE parameters if forwarding is enabled + proxy_code_verifier = None + proxy_code_challenge = None + if self._forward_pkce and params.code_challenge: + proxy_code_verifier, proxy_code_challenge = self._generate_pkce_pair() + logger.debug( + "Generated proxy PKCE for transaction %s (forwarding client PKCE to upstream)", + txn_id, + ) + + # Store transaction data for IdP callback processing + await self._transaction_store.put( + key=txn_id, + value=OAuthTransaction( + txn_id=txn_id, + client_id=client.client_id, + client_redirect_uri=str(params.redirect_uri), + client_state=params.state or "", + code_challenge=params.code_challenge, + code_challenge_method=getattr(params, "code_challenge_method", "S256"), + scopes=params.scopes or [], + created_at=time.time(), + resource=getattr(params, "resource", None), + proxy_code_verifier=proxy_code_verifier, + ), + ttl=15 * 60, # Auto-expire after 15 minutes + ) + + consent_url = f"{str(self.base_url).rstrip('/')}/consent?txn_id={txn_id}" + + logger.debug( + "Starting OAuth transaction %s for client %s, redirecting to consent page (PKCE forwarding: %s)", + txn_id, + client.client_id, + "enabled" if proxy_code_challenge else "disabled", + ) + return consent_url + + # ------------------------------------------------------------------------- + # Authorization Code Handling + # ------------------------------------------------------------------------- + + async def load_authorization_code( + self, + client: OAuthClientInformationFull, + authorization_code: str, + ) -> AuthorizationCode | None: + """Load authorization code for validation. + + Look up our client code and return authorization code object + with PKCE challenge for validation. + """ + # Look up client code data + code_model = await self._code_store.get(key=authorization_code) + if not code_model: + logger.debug("Authorization code not found: %s", authorization_code) + return None + + # Check if code expired + if time.time() > code_model.expires_at: + logger.debug("Authorization code expired: %s", authorization_code) + await self._code_store.delete(key=authorization_code) + return None + + # Verify client ID matches + if code_model.client_id != client.client_id: + logger.debug( + "Authorization code client ID mismatch: %s vs %s", + code_model.client_id, + client.client_id, + ) + return None + + # Create authorization code object with PKCE challenge + return AuthorizationCode( + code=authorization_code, + client_id=client.client_id, + redirect_uri=code_model.redirect_uri, + redirect_uri_provided_explicitly=True, + scopes=code_model.scopes, + expires_at=code_model.expires_at, + code_challenge=code_model.code_challenge or "", + ) + + async def exchange_authorization_code( + self, + client: OAuthClientInformationFull, + authorization_code: AuthorizationCode, + ) -> OAuthToken: + """Exchange authorization code for FastMCP-issued tokens. + + Implements the token factory pattern: + 1. Retrieves upstream tokens from stored authorization code + 2. Extracts user identity from upstream token + 3. Encrypts and stores upstream tokens + 4. Issues FastMCP-signed JWT tokens + 5. Returns FastMCP tokens (NOT upstream tokens) + + PKCE validation is handled by the MCP framework before this method is called. + """ + # Ensure JWT issuer is initialized + await self._ensure_jwt_initialized() + assert self._jwt_issuer is not None + assert self._token_encryption is not None + + # Look up stored code data + code_model = await self._code_store.get(key=authorization_code.code) + if not code_model: + logger.error( + "Authorization code not found in client codes: %s", + authorization_code.code, + ) + raise TokenError("invalid_grant", "Authorization code not found") + + # Get stored upstream tokens + idp_tokens = code_model.idp_tokens + + # Clean up client code (one-time use) + await self._code_store.delete(key=authorization_code.code) + + # Generate IDs for token storage + upstream_token_id = secrets.token_urlsafe(32) + access_jti = secrets.token_urlsafe(32) + refresh_jti = ( + secrets.token_urlsafe(32) if idp_tokens.get("refresh_token") else None + ) + + # Calculate token expiry times + expires_in = int( + idp_tokens.get("expires_in", DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS) + ) + + # Calculate refresh token expiry if provided by upstream + # Some providers include refresh_expires_in, some don't + refresh_expires_in = None + refresh_token_expires_at = None + if idp_tokens.get("refresh_token"): + if "refresh_expires_in" in idp_tokens: + refresh_expires_in = int(idp_tokens["refresh_expires_in"]) + refresh_token_expires_at = time.time() + refresh_expires_in + logger.debug( + "Upstream refresh token expires in %d seconds", refresh_expires_in + ) + else: + # Default to 30 days if upstream doesn't specify + # This is conservative - most providers use longer expiry + refresh_expires_in = 60 * 60 * 24 * 30 # 30 days + refresh_token_expires_at = time.time() + refresh_expires_in + logger.debug( + "Upstream refresh token expiry unknown, using 30-day default" + ) + + # Encrypt and store upstream tokens + upstream_token_set = UpstreamTokenSet( + upstream_token_id=upstream_token_id, + access_token=self._token_encryption.encrypt(idp_tokens["access_token"]), + refresh_token=self._token_encryption.encrypt(idp_tokens["refresh_token"]) + if idp_tokens.get("refresh_token") + else None, + refresh_token_expires_at=refresh_token_expires_at, + expires_at=time.time() + expires_in, + token_type=idp_tokens.get("token_type", "Bearer"), + scope=" ".join(authorization_code.scopes), + client_id=client.client_id, + created_at=time.time(), + raw_token_data=idp_tokens, + ) + await self._upstream_token_store.put( + key=upstream_token_id, + value=upstream_token_set, + ttl=expires_in, # Auto-expire when access token expires + ) + logger.debug("Stored encrypted upstream tokens (jti=%s)", access_jti[:8]) + + # Issue minimal FastMCP access token (just a reference via JTI) + fastmcp_access_token = self._jwt_issuer.issue_access_token( + client_id=client.client_id, + scopes=authorization_code.scopes, + jti=access_jti, + expires_in=expires_in, + ) + + # Issue minimal FastMCP refresh token if upstream provided one + # Use upstream refresh token expiry to align lifetimes + fastmcp_refresh_token = None + if refresh_jti and refresh_expires_in: + fastmcp_refresh_token = self._jwt_issuer.issue_refresh_token( + client_id=client.client_id, + scopes=authorization_code.scopes, + jti=refresh_jti, + expires_in=refresh_expires_in, + ) + + # Store JTI mappings + await self._jti_mapping_store.put( + key=access_jti, + value=JTIMapping( + jti=access_jti, + upstream_token_id=upstream_token_id, + created_at=time.time(), + ), + ttl=expires_in, # Auto-expire with access token + ) + if refresh_jti: + await self._jti_mapping_store.put( + key=refresh_jti, + value=JTIMapping( + jti=refresh_jti, + upstream_token_id=upstream_token_id, + created_at=time.time(), + ), + ttl=60 * 60 * 24 * 30, # Auto-expire with refresh token (30 days) + ) + + # Store FastMCP access token for MCP framework validation + self._access_tokens[fastmcp_access_token] = AccessToken( + token=fastmcp_access_token, + client_id=client.client_id, + scopes=authorization_code.scopes, + expires_at=int(time.time() + expires_in), + ) + + # Store FastMCP refresh token if provided + if fastmcp_refresh_token: + self._refresh_tokens[fastmcp_refresh_token] = RefreshToken( + token=fastmcp_refresh_token, + client_id=client.client_id, + scopes=authorization_code.scopes, + expires_at=None, + ) + # Maintain token relationships for cleanup + self._access_to_refresh[fastmcp_access_token] = fastmcp_refresh_token + self._refresh_to_access[fastmcp_refresh_token] = fastmcp_access_token + + logger.debug( + "Issued FastMCP tokens for client=%s (access_jti=%s, refresh_jti=%s)", + client.client_id, + access_jti[:8], + refresh_jti[:8] if refresh_jti else "none", + ) + + # Return FastMCP-issued tokens (NOT upstream tokens!) + return OAuthToken( + access_token=fastmcp_access_token, + token_type="Bearer", + expires_in=expires_in, + refresh_token=fastmcp_refresh_token, + scope=" ".join(authorization_code.scopes), + ) + + # ------------------------------------------------------------------------- + # Refresh Token Flow + # ------------------------------------------------------------------------- + + async def load_refresh_token( + self, + client: OAuthClientInformationFull, + refresh_token: str, + ) -> RefreshToken | None: + """Load refresh token from local storage.""" + return self._refresh_tokens.get(refresh_token) + + async def exchange_refresh_token( + self, + client: OAuthClientInformationFull, + refresh_token: RefreshToken, + scopes: list[str], + ) -> OAuthToken: + """Exchange FastMCP refresh token for new FastMCP access token. + + Implements two-tier refresh: + 1. Verify FastMCP refresh token + 2. Look up upstream token via JTI mapping + 3. Refresh upstream token with upstream provider + 4. Update stored upstream token + 5. Issue new FastMCP access token + 6. Keep same FastMCP refresh token (unless upstream rotates) + """ + # Ensure JWT issuer is initialized + await self._ensure_jwt_initialized() + assert self._jwt_issuer is not None + assert self._token_encryption is not None + + # Verify FastMCP refresh token + try: + refresh_payload = self._jwt_issuer.verify_token(refresh_token.token) + refresh_jti = refresh_payload["jti"] + except Exception as e: + logger.debug("FastMCP refresh token validation failed: %s", e) + raise TokenError("invalid_grant", "Invalid refresh token") from e + + # Look up upstream token via JTI mapping + jti_mapping = await self._jti_mapping_store.get(key=refresh_jti) + if not jti_mapping: + logger.error("JTI mapping not found for refresh token: %s", refresh_jti[:8]) + raise TokenError("invalid_grant", "Refresh token mapping not found") + + upstream_token_set = await self._upstream_token_store.get( + key=jti_mapping.upstream_token_id + ) + if not upstream_token_set: + logger.error( + "Upstream token set not found: %s", jti_mapping.upstream_token_id[:8] + ) + raise TokenError("invalid_grant", "Upstream token not found") + + # Decrypt upstream refresh token + if not upstream_token_set.refresh_token: + logger.error("No upstream refresh token available") + raise TokenError("invalid_grant", "Refresh not supported for this token") + + upstream_refresh_token = self._token_encryption.decrypt( + upstream_token_set.refresh_token + ) + + # Refresh upstream token using authlib + oauth_client = AsyncOAuth2Client( + client_id=self._upstream_client_id, + client_secret=self._upstream_client_secret.get_secret_value(), + token_endpoint_auth_method=self._token_endpoint_auth_method, + timeout=HTTP_TIMEOUT_SECONDS, + ) + + try: + logger.debug("Refreshing upstream token (jti=%s)", refresh_jti[:8]) + token_response: dict[str, Any] = await oauth_client.refresh_token( # type: ignore[misc] + url=self._upstream_token_endpoint, + refresh_token=upstream_refresh_token, + scope=" ".join(scopes) if scopes else None, + ) + logger.debug("Successfully refreshed upstream token") + except Exception as e: + logger.error("Upstream token refresh failed: %s", e) + raise TokenError("invalid_grant", f"Upstream refresh failed: {e}") from e + + # Update stored upstream token + new_expires_in = int( + token_response.get("expires_in", DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS) + ) + upstream_token_set.access_token = self._token_encryption.encrypt( + token_response["access_token"] + ) + upstream_token_set.expires_at = time.time() + new_expires_in + + # Handle upstream refresh token rotation and expiry + new_refresh_expires_in = None + if new_upstream_refresh := token_response.get("refresh_token"): + if new_upstream_refresh != upstream_refresh_token: + upstream_token_set.refresh_token = self._token_encryption.encrypt( + new_upstream_refresh + ) + logger.debug("Upstream refresh token rotated") + + # Update refresh token expiry if provided + if "refresh_expires_in" in token_response: + new_refresh_expires_in = int(token_response["refresh_expires_in"]) + upstream_token_set.refresh_token_expires_at = ( + time.time() + new_refresh_expires_in + ) + logger.debug( + "Upstream refresh token expires in %d seconds", + new_refresh_expires_in, + ) + elif upstream_token_set.refresh_token_expires_at: + # Keep existing expiry if upstream doesn't provide new one + new_refresh_expires_in = int( + upstream_token_set.refresh_token_expires_at - time.time() + ) + else: + # Default to 30 days if unknown + new_refresh_expires_in = 60 * 60 * 24 * 30 + upstream_token_set.refresh_token_expires_at = ( + time.time() + new_refresh_expires_in + ) + + upstream_token_set.raw_token_data = token_response + await self._upstream_token_store.put( + key=upstream_token_set.upstream_token_id, + value=upstream_token_set, + ttl=new_expires_in, # Auto-expire when refreshed access token expires + ) + + # Issue new minimal FastMCP access token (just a reference via JTI) + new_access_jti = secrets.token_urlsafe(32) + new_fastmcp_access = self._jwt_issuer.issue_access_token( + client_id=client.client_id, + scopes=scopes, + jti=new_access_jti, + expires_in=new_expires_in, + ) + + # Store new access token JTI mapping + await self._jti_mapping_store.put( + key=new_access_jti, + value=JTIMapping( + jti=new_access_jti, + upstream_token_id=upstream_token_set.upstream_token_id, + created_at=time.time(), + ), + ttl=new_expires_in, # Auto-expire with refreshed access token + ) + + # Issue NEW minimal FastMCP refresh token (rotation for security) + # Use upstream refresh token expiry to align lifetimes + new_refresh_jti = secrets.token_urlsafe(32) + new_fastmcp_refresh = self._jwt_issuer.issue_refresh_token( + client_id=client.client_id, + scopes=scopes, + jti=new_refresh_jti, + expires_in=new_refresh_expires_in + or 60 * 60 * 24 * 30, # Fallback to 30 days + ) + + # Store new refresh token JTI mapping with aligned expiry + refresh_ttl = new_refresh_expires_in or 60 * 60 * 24 * 30 + await self._jti_mapping_store.put( + key=new_refresh_jti, + value=JTIMapping( + jti=new_refresh_jti, + upstream_token_id=upstream_token_set.upstream_token_id, + created_at=time.time(), + ), + ttl=refresh_ttl, # Align with upstream refresh token expiry + ) + + # Invalidate old refresh token (refresh token rotation - enforces one-time use) + await self._jti_mapping_store.delete(key=refresh_jti) + logger.debug( + "Rotated refresh token (old JTI invalidated - one-time use enforced)" + ) + + # Update local token tracking + self._access_tokens[new_fastmcp_access] = AccessToken( + token=new_fastmcp_access, + client_id=client.client_id, + scopes=scopes, + expires_at=int(time.time() + new_expires_in), + ) + self._refresh_tokens[new_fastmcp_refresh] = RefreshToken( + token=new_fastmcp_refresh, + client_id=client.client_id, + scopes=scopes, + expires_at=None, + ) + + # Update token relationship mappings + self._access_to_refresh[new_fastmcp_access] = new_fastmcp_refresh + self._refresh_to_access[new_fastmcp_refresh] = new_fastmcp_access + + # Clean up old token from in-memory tracking + self._refresh_tokens.pop(refresh_token.token, None) + old_access = self._refresh_to_access.pop(refresh_token.token, None) + if old_access: + self._access_tokens.pop(old_access, None) + self._access_to_refresh.pop(old_access, None) + + logger.info( + "Issued new FastMCP tokens (rotated refresh) for client=%s (access_jti=%s, refresh_jti=%s)", + client.client_id, + new_access_jti[:8], + new_refresh_jti[:8], + ) + + # Return new FastMCP tokens (both access AND refresh are new) + return OAuthToken( + access_token=new_fastmcp_access, + token_type="Bearer", + expires_in=new_expires_in, + refresh_token=new_fastmcp_refresh, # NEW refresh token (rotated) + scope=" ".join(scopes), + ) + + # ------------------------------------------------------------------------- + # Token Validation + # ------------------------------------------------------------------------- + + async def load_access_token(self, token: str) -> AccessToken | None: + """Validate FastMCP JWT by swapping for upstream token. + + This implements the token swap pattern: + 1. Verify FastMCP JWT signature (proves it's our token) + 2. Look up upstream token via JTI mapping + 3. Decrypt upstream token + 4. Validate upstream token with provider (GitHub API, JWT validation, etc.) + 5. Return upstream validation result + + The FastMCP JWT is a reference token - all authorization data comes + from validating the upstream token via the TokenVerifier. + """ + # Ensure JWT issuer and encryption are initialized + await self._ensure_jwt_initialized() + assert self._jwt_issuer is not None + assert self._token_encryption is not None + + try: + # 1. Verify FastMCP JWT signature and claims + payload = self._jwt_issuer.verify_token(token) + jti = payload["jti"] + + # 2. Look up upstream token via JTI mapping + jti_mapping = await self._jti_mapping_store.get(key=jti) + if not jti_mapping: + logger.debug("JTI mapping not found: %s", jti) + return None + + upstream_token_set = await self._upstream_token_store.get( + key=jti_mapping.upstream_token_id + ) + if not upstream_token_set: + logger.debug( + "Upstream token not found: %s", jti_mapping.upstream_token_id + ) + return None + + # 3. Decrypt upstream token + upstream_token = self._token_encryption.decrypt( + upstream_token_set.access_token + ) + + # 4. Validate with upstream provider (delegated to TokenVerifier) + # This calls the real token validator (GitHub API, JWKS, etc.) + validated = await self._token_validator.verify_token(upstream_token) + + if not validated: + logger.debug("Upstream token validation failed") + return None + + logger.debug( + "Token swap successful for JTI=%s (upstream validated)", jti[:8] + ) + return validated + + except Exception as e: + logger.debug("Token swap validation failed: %s", e) + return None + + # ------------------------------------------------------------------------- + # Token Revocation + # ------------------------------------------------------------------------- + + async def revoke_token(self, token: AccessToken | RefreshToken) -> None: + """Revoke token locally and with upstream server if supported. + + Removes tokens from local storage and attempts to revoke them with + the upstream server if a revocation endpoint is configured. + """ + # Clean up local token storage + if isinstance(token, AccessToken): + self._access_tokens.pop(token.token, None) + # Also remove associated refresh token + paired_refresh = self._access_to_refresh.pop(token.token, None) + if paired_refresh: + self._refresh_tokens.pop(paired_refresh, None) + self._refresh_to_access.pop(paired_refresh, None) + else: # RefreshToken + self._refresh_tokens.pop(token.token, None) + # Also remove associated access token + paired_access = self._refresh_to_access.pop(token.token, None) + if paired_access: + self._access_tokens.pop(paired_access, None) + self._access_to_refresh.pop(paired_access, None) + + # Attempt upstream revocation if endpoint is configured + if self._upstream_revocation_endpoint: + try: + async with httpx.AsyncClient( + timeout=HTTP_TIMEOUT_SECONDS + ) as http_client: + await http_client.post( + self._upstream_revocation_endpoint, + data={"token": token.token}, + auth=( + self._upstream_client_id, + self._upstream_client_secret.get_secret_value(), + ), + ) + logger.debug("Successfully revoked token with upstream server") + except Exception as e: + logger.warning("Failed to revoke token with upstream server: %s", e) + else: + logger.debug("No upstream revocation endpoint configured") + + logger.debug("Token revoked successfully") + + def get_routes( + self, + mcp_path: str | None = None, + ) -> list[Route]: + """Get OAuth routes with custom proxy token handler. + + This method creates standard OAuth routes and replaces the token endpoint + with our proxy handler that forwards requests to the upstream OAuth server. + + Args: + mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") + This is used to advertise the resource URL in metadata. + """ + # Get standard OAuth routes from parent class + routes = super().get_routes(mcp_path) + custom_routes = [] + token_route_found = False + + logger.debug( + f"get_routes called - configuring OAuth routes in {len(routes)} routes" + ) + + for i, route in enumerate(routes): + logger.debug( + f"Route {i}: {route} - path: {getattr(route, 'path', 'N/A')}, methods: {getattr(route, 'methods', 'N/A')}" + ) + + # Replace the token endpoint with our custom handler that returns proper OAuth 2.1 error codes + if ( + isinstance(route, Route) + and route.path == "/token" + and route.methods is not None + and "POST" in route.methods + ): + token_route_found = True + # Replace with our OAuth 2.1 compliant token handler + token_handler = TokenHandler( + provider=self, client_authenticator=ClientAuthenticator(self) + ) + custom_routes.append( + Route( + path="/token", + endpoint=cors_middleware( + token_handler.handle, ["POST", "OPTIONS"] + ), + methods=["POST", "OPTIONS"], + ) + ) + else: + # Keep all other standard OAuth routes unchanged + custom_routes.append(route) + + # Add OAuth callback endpoint for forwarding to client callbacks + custom_routes.append( + Route( + path=self._redirect_path, + endpoint=self._handle_idp_callback, + methods=["GET"], + ) + ) + + # Add consent endpoints + custom_routes.append( + Route(path="/consent", endpoint=self._show_consent_page, methods=["GET"]) + ) + custom_routes.append( + Route( + path="/consent/submit", endpoint=self._submit_consent, methods=["POST"] + ) + ) + + logger.debug( + f"✅ OAuth routes configured: token_endpoint={token_route_found}, total routes={len(custom_routes)} (includes OAuth callback + consent)" + ) + return custom_routes + + # ------------------------------------------------------------------------- + # IdP Callback Forwarding + # ------------------------------------------------------------------------- + + async def _handle_idp_callback(self, request: Request) -> RedirectResponse: + """Handle callback from upstream IdP and forward to client. + + This implements the DCR-compliant callback forwarding: + 1. Receive IdP callback with code and txn_id as state + 2. Exchange IdP code for tokens (server-side) + 3. Generate our own client code bound to PKCE challenge + 4. Redirect to client's callback with client code and original state + """ + try: + idp_code = request.query_params.get("code") + txn_id = request.query_params.get("state") + error = request.query_params.get("error") + + if error: + logger.error( + "IdP callback error: %s - %s", + error, + request.query_params.get("error_description"), + ) + # TODO: Forward error to client callback + return RedirectResponse( + url=f"data:text/html,

OAuth Error

{error}: {request.query_params.get('error_description', 'Unknown error')}

", + status_code=302, + ) + + if not idp_code or not txn_id: + logger.error("IdP callback missing code or transaction ID") + return RedirectResponse( + url="data:text/html,

OAuth Error

Missing authorization code or transaction ID

", + status_code=302, + ) + + # Look up transaction data + transaction_model = await self._transaction_store.get(key=txn_id) + if not transaction_model: + logger.error("IdP callback with invalid transaction ID: %s", txn_id) + return RedirectResponse( + url="data:text/html,

OAuth Error

Invalid or expired transaction

", + status_code=302, + ) + transaction = transaction_model.model_dump() + + # Exchange IdP code for tokens (server-side) + oauth_client = AsyncOAuth2Client( + client_id=self._upstream_client_id, + client_secret=self._upstream_client_secret.get_secret_value(), + token_endpoint_auth_method=self._token_endpoint_auth_method, + timeout=HTTP_TIMEOUT_SECONDS, + ) + + try: + idp_redirect_uri = ( + f"{str(self.base_url).rstrip('/')}{self._redirect_path}" + ) + logger.debug( + f"Exchanging IdP code for tokens with redirect_uri: {idp_redirect_uri}" + ) + + # Build token exchange parameters + token_params = { + "url": self._upstream_token_endpoint, + "code": idp_code, + "redirect_uri": idp_redirect_uri, + } + + # Include proxy's code_verifier if we forwarded PKCE + proxy_code_verifier = transaction.get("proxy_code_verifier") + if proxy_code_verifier: + token_params["code_verifier"] = proxy_code_verifier + logger.debug( + "Including proxy code_verifier in token exchange for transaction %s", + txn_id, + ) + + # Add any extra token parameters configured for this proxy + if self._extra_token_params: + token_params.update(self._extra_token_params) + logger.debug( + "Adding extra token parameters for transaction %s: %s", + txn_id, + list(self._extra_token_params.keys()), + ) + + idp_tokens: dict[str, Any] = await oauth_client.fetch_token( + **token_params + ) # type: ignore[misc] + + logger.debug( + f"Successfully exchanged IdP code for tokens (transaction: {txn_id}, PKCE: {bool(proxy_code_verifier)})" + ) + + except Exception as e: + logger.error("IdP token exchange failed: %s", e) + # TODO: Forward error to client callback + return RedirectResponse( + url=f"data:text/html,

OAuth Error

Token exchange failed: {e}

", + status_code=302, + ) + + # Generate our own authorization code for the client + client_code = secrets.token_urlsafe(32) + code_expires_at = int(time.time() + DEFAULT_AUTH_CODE_EXPIRY_SECONDS) + + # Store client code with PKCE challenge and IdP tokens + await self._code_store.put( + key=client_code, + value=ClientCode( + code=client_code, + client_id=transaction["client_id"], + redirect_uri=transaction["client_redirect_uri"], + code_challenge=transaction["code_challenge"], + code_challenge_method=transaction["code_challenge_method"], + scopes=transaction["scopes"], + idp_tokens=idp_tokens, + expires_at=code_expires_at, + created_at=time.time(), + ), + ttl=DEFAULT_AUTH_CODE_EXPIRY_SECONDS, # Auto-expire after 5 minutes + ) + + # Clean up transaction + await self._transaction_store.delete(key=txn_id) + + # Build client callback URL with our code and original state + client_redirect_uri = transaction["client_redirect_uri"] + client_state = transaction["client_state"] + + callback_params = { + "code": client_code, + "state": client_state, + } + + # Add query parameters to client redirect URI + separator = "&" if "?" in client_redirect_uri else "?" + client_callback_url = ( + f"{client_redirect_uri}{separator}{urlencode(callback_params)}" + ) + + logger.debug(f"Forwarding to client callback for transaction {txn_id}") + + return RedirectResponse(url=client_callback_url, status_code=302) + + except Exception as e: + logger.error("Error in IdP callback handler: %s", e, exc_info=True) + return RedirectResponse( + url="data:text/html,

OAuth Error

Internal server error during IdP callback

", + status_code=302, + ) + + # ------------------------------------------------------------------------- + # Consent Interstitial + # ------------------------------------------------------------------------- + + def _normalize_uri(self, uri: str) -> str: + """Normalize a URI to a canonical form for consent tracking.""" + parsed = urlparse(uri) + path = parsed.path or "" + normalized = f"{parsed.scheme.lower()}://{parsed.netloc.lower()}{path}" + if normalized.endswith("/") and len(path) > 1: + normalized = normalized[:-1] + return normalized + + def _make_client_key(self, client_id: str, redirect_uri: str | AnyUrl) -> str: + """Create a stable key for consent tracking from client_id and redirect_uri.""" + normalized = self._normalize_uri(str(redirect_uri)) + return f"{client_id}:{normalized}" + + def _cookie_name(self, base_name: str) -> str: + """Return secure cookie name for HTTPS, fallback for HTTP development.""" + if self._is_https: + return f"__Host-{base_name}" + return f"__{base_name}" + + def _sign_cookie(self, payload: str) -> str: + """Sign a cookie payload with HMAC-SHA256. + + Returns: base64(payload).base64(signature) + """ + # Use upstream client secret as signing key + key = self._upstream_client_secret.get_secret_value().encode() + signature = hmac.new(key, payload.encode(), hashlib.sha256).digest() + signature_b64 = base64.b64encode(signature).decode() + return f"{payload}.{signature_b64}" + + def _verify_cookie(self, signed_value: str) -> str | None: + """Verify and extract payload from signed cookie. + + Returns: payload if signature valid, None otherwise + """ + try: + if "." not in signed_value: + return None + payload, signature_b64 = signed_value.rsplit(".", 1) + + # Verify signature + key = self._upstream_client_secret.get_secret_value().encode() + expected_sig = hmac.new(key, payload.encode(), hashlib.sha256).digest() + provided_sig = base64.b64decode(signature_b64.encode()) + + # Constant-time comparison + if not hmac.compare_digest(expected_sig, provided_sig): + return None + + return payload + except Exception: + return None + + def _decode_list_cookie(self, request: Request, base_name: str) -> list[str]: + """Decode and verify a signed base64-encoded JSON list from cookie. Returns [] if missing/invalid.""" + # Prefer secure name, but also check non-secure variant for dev + secure_name = self._cookie_name(base_name) + raw = request.cookies.get(secure_name) or request.cookies.get(f"__{base_name}") + if not raw: + return [] + try: + # Verify signature + payload = self._verify_cookie(raw) + if not payload: + logger.debug("Cookie signature verification failed for %s", secure_name) + return [] + + # Decode payload + data = base64.b64decode(payload.encode()) + value = json.loads(data.decode()) + if isinstance(value, list): + return [str(x) for x in value] + except Exception: + logger.debug("Failed to decode cookie %s; treating as empty", secure_name) + return [] + + def _encode_list_cookie(self, values: list[str]) -> str: + """Encode values to base64 and sign with HMAC. + + Returns: signed cookie value (payload.signature) + """ + payload = json.dumps(values, separators=(",", ":")).encode() + payload_b64 = base64.b64encode(payload).decode() + return self._sign_cookie(payload_b64) + + def _set_list_cookie( + self, + response: HTMLResponse | RedirectResponse, + base_name: str, + value_b64: str, + max_age: int, + ) -> None: + name = self._cookie_name(base_name) + response.set_cookie( + name, + value_b64, + max_age=max_age, + secure=self._is_https, + httponly=True, + samesite="lax", + path="/", + ) + + def _build_upstream_authorize_url( + self, txn_id: str, transaction: dict[str, Any] + ) -> str: + """Construct the upstream IdP authorization URL using stored transaction data.""" + query_params: dict[str, Any] = { + "response_type": "code", + "client_id": self._upstream_client_id, + "redirect_uri": f"{str(self.base_url).rstrip('/')}{self._redirect_path}", + "state": txn_id, + } + + scopes_to_use = transaction.get("scopes") or self.required_scopes or [] + if scopes_to_use: + query_params["scope"] = " ".join(scopes_to_use) + + # If PKCE forwarding was enabled, include the proxy challenge + proxy_code_verifier = transaction.get("proxy_code_verifier") + if proxy_code_verifier: + challenge_bytes = hashlib.sha256(proxy_code_verifier.encode()).digest() + proxy_code_challenge = ( + urlsafe_b64encode(challenge_bytes).decode().rstrip("=") + ) + query_params["code_challenge"] = proxy_code_challenge + query_params["code_challenge_method"] = "S256" + + # Forward resource indicator if present in transaction + if resource := transaction.get("resource"): + query_params["resource"] = resource + + # Extra configured parameters + if self._extra_authorize_params: + query_params.update(self._extra_authorize_params) + + separator = "&" if "?" in self._upstream_authorization_endpoint else "?" + return f"{self._upstream_authorization_endpoint}{separator}{urlencode(query_params)}" + + async def _show_consent_page( + self, request: Request + ) -> HTMLResponse | RedirectResponse: + """Display consent page or auto-approve/deny based on cookies.""" + from fastmcp.server.server import FastMCP + + txn_id = request.query_params.get("txn_id") + if not txn_id: + return create_secure_html_response( + "

Error

Invalid or expired transaction

", status_code=400 + ) + + txn_model = await self._transaction_store.get(key=txn_id) + if not txn_model: + return create_secure_html_response( + "

Error

Invalid or expired transaction

", status_code=400 + ) + + txn = txn_model.model_dump() + client_key = self._make_client_key(txn["client_id"], txn["client_redirect_uri"]) + + approved = set(self._decode_list_cookie(request, "MCP_APPROVED_CLIENTS")) + denied = set(self._decode_list_cookie(request, "MCP_DENIED_CLIENTS")) + + if client_key in approved: + upstream_url = self._build_upstream_authorize_url(txn_id, txn) + return RedirectResponse(url=upstream_url, status_code=302) + + if client_key in denied: + callback_params = { + "error": "access_denied", + "state": txn.get("client_state") or "", + } + sep = "&" if "?" in txn["client_redirect_uri"] else "?" + return RedirectResponse( + url=f"{txn['client_redirect_uri']}{sep}{urlencode(callback_params)}", + status_code=302, + ) + + # Need consent: issue CSRF token and show HTML + csrf_token = secrets.token_urlsafe(32) + csrf_expires_at = time.time() + 15 * 60 + + # Update transaction with CSRF token + txn_model.csrf_token = csrf_token + txn_model.csrf_expires_at = csrf_expires_at + await self._transaction_store.put( + key=txn_id, value=txn_model, ttl=15 * 60 + ) # Auto-expire after 15 minutes + + # Update dict for use in HTML generation + txn["csrf_token"] = csrf_token + txn["csrf_expires_at"] = csrf_expires_at + + # Load client to get client_name if available + client = await self.get_client(txn["client_id"]) + client_name = getattr(client, "client_name", None) if client else None + + # Extract server metadata from app state + fastmcp = getattr(request.app.state, "fastmcp_server", None) + + if isinstance(fastmcp, FastMCP): + server_name = fastmcp.name + icons = fastmcp.icons + server_icon_url = icons[0].src if icons else None + server_website_url = fastmcp.website_url + else: + server_name = None + server_icon_url = None + server_website_url = None + + html = create_consent_html( + client_id=txn["client_id"], + redirect_uri=txn["client_redirect_uri"], + scopes=txn.get("scopes") or [], + txn_id=txn_id, + csrf_token=csrf_token, + client_name=client_name, + server_name=server_name, + server_icon_url=server_icon_url, + server_website_url=server_website_url, + ) + response = create_secure_html_response(html) + # Store CSRF in cookie with short lifetime + self._set_list_cookie( + response, + "MCP_CONSENT_STATE", + self._encode_list_cookie([csrf_token]), + max_age=15 * 60, + ) + return response + + async def _submit_consent( + self, request: Request + ) -> RedirectResponse | HTMLResponse: + """Handle consent approval/denial, set cookies, and redirect appropriately.""" + form = await request.form() + txn_id = str(form.get("txn_id", "")) + action = str(form.get("action", "")) + csrf_token = str(form.get("csrf_token", "")) + + if not txn_id: + return create_secure_html_response( + "

Error

Invalid or expired transaction

", status_code=400 + ) + + txn_model = await self._transaction_store.get(key=txn_id) + if not txn_model: + return create_secure_html_response( + "

Error

Invalid or expired transaction

", status_code=400 + ) + + txn = txn_model.model_dump() + expected_csrf = txn.get("csrf_token") + expires_at = float(txn.get("csrf_expires_at") or 0) + + if not expected_csrf or csrf_token != expected_csrf or time.time() > expires_at: + return create_secure_html_response( + "

Error

Invalid or expired consent token

", status_code=400 + ) + + client_key = self._make_client_key(txn["client_id"], txn["client_redirect_uri"]) + + if action == "approve": + approved = set(self._decode_list_cookie(request, "MCP_APPROVED_CLIENTS")) + if client_key not in approved: + approved.add(client_key) + approved_b64 = self._encode_list_cookie(sorted(approved)) + + upstream_url = self._build_upstream_authorize_url(txn_id, txn) + response = RedirectResponse(url=upstream_url, status_code=302) + self._set_list_cookie( + response, "MCP_APPROVED_CLIENTS", approved_b64, max_age=365 * 24 * 3600 + ) + # Clear CSRF cookie by setting empty short-lived value + self._set_list_cookie( + response, "MCP_CONSENT_STATE", self._encode_list_cookie([]), max_age=60 + ) + return response + + elif action == "deny": + denied = set(self._decode_list_cookie(request, "MCP_DENIED_CLIENTS")) + if client_key not in denied: + denied.add(client_key) + denied_b64 = self._encode_list_cookie(sorted(denied)) + + callback_params = { + "error": "access_denied", + "state": txn.get("client_state") or "", + } + sep = "&" if "?" in txn["client_redirect_uri"] else "?" + client_callback_url = ( + f"{txn['client_redirect_uri']}{sep}{urlencode(callback_params)}" + ) + response = RedirectResponse(url=client_callback_url, status_code=302) + self._set_list_cookie( + response, "MCP_DENIED_CLIENTS", denied_b64, max_age=365 * 24 * 3600 + ) + self._set_list_cookie( + response, "MCP_CONSENT_STATE", self._encode_list_cookie([]), max_age=60 + ) + return response + + else: + return create_secure_html_response( + "

Error

Invalid action

", status_code=400 + ) diff --git a/src/fastmcp/server/auth/oauth_proxy.py b/src/fastmcp/server/auth/oauth_proxy.py index 9729bcea2..197c7ef0f 100644 --- a/src/fastmcp/server/auth/oauth_proxy.py +++ b/src/fastmcp/server/auth/oauth_proxy.py @@ -1,2022 +1,24 @@ -"""OAuth Proxy Provider for FastMCP. +"""Backwards compatibility shim for oauth_proxy.py -This provider acts as a transparent proxy to an upstream OAuth Authorization Server, -handling Dynamic Client Registration locally while forwarding all other OAuth flows. -This enables authentication with upstream providers that don't support DCR or have -restricted client registration policies. - -Key features: -- Proxies authorization and token endpoints to upstream server -- Implements local Dynamic Client Registration with fixed upstream credentials -- Validates tokens using upstream JWKS -- Maintains minimal local state for bookkeeping -- Enhanced logging with request correlation - -This implementation is based on the OAuth 2.1 specification and is designed for -production use with enterprise identity providers. +The OauthProxy class has been moved to fastmcp.server.auth.oauth_dcr_proxy.OAuthDCRProxy +for better organization. This module provides a backwards-compatible import. """ -from __future__ import annotations +import warnings -import base64 -import hashlib -import hmac -import json -import secrets -import time -from base64 import urlsafe_b64encode -from typing import TYPE_CHECKING, Any, Final -from urllib.parse import urlencode, urlparse +import fastmcp +from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy as OAuthProxy -import httpx -from authlib.common.security import generate_token -from authlib.integrations.httpx_client import AsyncOAuth2Client -from key_value.aio.adapters.pydantic import PydanticAdapter -from key_value.aio.protocols import AsyncKeyValue -from key_value.aio.stores.memory import MemoryStore -from mcp.server.auth.handlers.token import TokenErrorResponse, TokenSuccessResponse -from mcp.server.auth.handlers.token import TokenHandler as _SDKTokenHandler -from mcp.server.auth.json_response import PydanticJSONResponse -from mcp.server.auth.middleware.client_auth import ClientAuthenticator -from mcp.server.auth.provider import ( - AccessToken, - AuthorizationCode, - AuthorizationParams, - RefreshToken, - TokenError, -) -from mcp.server.auth.routes import cors_middleware -from mcp.server.auth.settings import ( - ClientRegistrationOptions, - RevocationOptions, -) -from mcp.shared.auth import OAuthClientInformationFull, OAuthToken -from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, SecretStr -from starlette.requests import Request -from starlette.responses import HTMLResponse, RedirectResponse -from starlette.routing import Route +# Re-export for backwards compatibility +__all__ = ["OAuthProxy"] -from fastmcp.server.auth.auth import OAuthProvider, TokenVerifier -from fastmcp.server.auth.jwt_issuer import ( - JWTIssuer, - TokenEncryption, -) -from fastmcp.server.auth.redirect_validation import ( - validate_redirect_uri, -) -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.ui import ( - BUTTON_STYLES, - DETAIL_BOX_STYLES, - INFO_BOX_STYLES, - TOOLTIP_STYLES, - create_detail_box, - create_logo, - create_page, - create_secure_html_response, -) - -if TYPE_CHECKING: - pass - -logger = get_logger(__name__) - - -# ------------------------------------------------------------------------- -# Constants -# ------------------------------------------------------------------------- - -# Default token expiration times -DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS: Final[int] = 60 * 60 # 1 hour -DEFAULT_AUTH_CODE_EXPIRY_SECONDS: Final[int] = 5 * 60 # 5 minutes - -# HTTP client timeout -HTTP_TIMEOUT_SECONDS: Final[int] = 30 - - -# ------------------------------------------------------------------------- -# Pydantic Models -# ------------------------------------------------------------------------- - - -class OAuthTransaction(BaseModel): - """OAuth transaction state for consent flow. - - Stored server-side to track active authorization flows with client context. - Includes CSRF tokens for consent protection per MCP security best practices. - """ - - txn_id: str - client_id: str - client_redirect_uri: str - client_state: str - code_challenge: str | None - code_challenge_method: str - scopes: list[str] - created_at: float - resource: str | None = None - proxy_code_verifier: str | None = None - csrf_token: str | None = None - csrf_expires_at: float | None = None - - -class ClientCode(BaseModel): - """Client authorization code with PKCE and upstream tokens. - - Stored server-side after upstream IdP callback. Contains the upstream - tokens bound to the client's PKCE challenge for secure token exchange. - """ - - code: str - client_id: str - redirect_uri: str - code_challenge: str | None - code_challenge_method: str - scopes: list[str] - idp_tokens: dict[str, Any] - expires_at: float - created_at: float - - -class UpstreamTokenSet(BaseModel): - """Stored upstream OAuth tokens from identity provider. - - These tokens are obtained from the upstream provider (Google, GitHub, etc.) - and are stored encrypted at rest. They are never exposed to MCP clients. - """ - - upstream_token_id: str # Unique ID for this token set - access_token: bytes # Encrypted upstream access token - refresh_token: bytes | None # Encrypted upstream refresh token - refresh_token_expires_at: ( - float | None - ) # Unix timestamp when refresh token expires (if known) - expires_at: float # Unix timestamp when access token expires - token_type: str # Usually "Bearer" - scope: str # Space-separated scopes - client_id: str # MCP client this is bound to - created_at: float # Unix timestamp - raw_token_data: dict[str, Any] = Field(default_factory=dict) # Full token response - - -class JTIMapping(BaseModel): - """Maps FastMCP token JTI to upstream token ID. - - This allows stateless JWT validation while still being able to look up - the corresponding upstream token when tools need to access upstream APIs. - """ - - jti: str # JWT ID from FastMCP-issued token - upstream_token_id: str # References UpstreamTokenSet - created_at: float # Unix timestamp - - -class ProxyDCRClient(OAuthClientInformationFull): - """Client for DCR proxy with configurable redirect URI validation. - - This special client class is critical for the OAuth proxy to work correctly - with Dynamic Client Registration (DCR). Here's why it exists: - - Problem: - -------- - When MCP clients use OAuth, they dynamically register with random localhost - ports (e.g., http://localhost:55454/callback). The OAuth proxy needs to: - 1. Accept these dynamic redirect URIs from clients based on configured patterns - 2. Use its own fixed redirect URI with the upstream provider (Google, GitHub, etc.) - 3. Forward the authorization code back to the client's dynamic URI - - Solution: - --------- - This class validates redirect URIs against configurable patterns, - while the proxy internally uses its own fixed redirect URI with the upstream - provider. This allows the flow to work even when clients reconnect with - different ports or when tokens are cached. - - Without proper validation, clients could get "Redirect URI not registered" errors - when trying to authenticate with cached tokens, or security vulnerabilities could - arise from accepting arbitrary redirect URIs. - """ - - allowed_redirect_uri_patterns: list[str] | None = Field(default=None) - client_name: str | None = Field(default=None) - - def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl: - """Validate redirect URI against allowed patterns. - - Since we're acting as a proxy and clients register dynamically, - we validate their redirect URIs against configurable patterns. - This is essential for cached token scenarios where the client may - reconnect with a different port. - """ - if redirect_uri is not None: - # Validate against allowed patterns - if validate_redirect_uri( - redirect_uri=redirect_uri, - allowed_patterns=self.allowed_redirect_uri_patterns, - ): - return redirect_uri - # Fall back to normal validation if not in allowed patterns - return super().validate_redirect_uri(redirect_uri) - # If no redirect_uri provided, use default behavior - return super().validate_redirect_uri(redirect_uri) - - -# ------------------------------------------------------------------------- -# Helper Functions -# ------------------------------------------------------------------------- - - -def create_consent_html( - client_id: str, - redirect_uri: str, - scopes: list[str], - txn_id: str, - csrf_token: str, - client_name: str | None = None, - title: str = "Authorization Consent", - server_name: str | None = None, - server_icon_url: str | None = None, - server_website_url: str | None = None, -) -> str: - """Create a styled HTML consent page for OAuth authorization requests.""" - # Format scopes for display - scopes_display = ", ".join(scopes) if scopes else "None" - - # Build warning box with client name if available - import html as html_module - - client_display = html_module.escape(client_name or client_id) - server_name_escaped = html_module.escape(server_name or "FastMCP") - - # Make server name a hyperlink if website URL is available - if server_website_url: - website_url_escaped = html_module.escape(server_website_url) - server_display = f'{server_name_escaped}' - else: - server_display = server_name_escaped - - warning_box = f""" -
-

{client_display} is requesting access to {server_display}.

-

Review the details below before approving.

-
- """ - - # Build detail box with client information - detail_rows = [] - if client_name: - detail_rows.append(("Client Name", client_name)) - detail_rows.extend( - [ - ("Client ID", client_id), - ("Redirect URI", redirect_uri), - ("Requested Scopes", scopes_display), - ] +# Deprecated in 2.13 +if fastmcp.settings.deprecation_warnings: + warnings.warn( + "The `fastmcp.server.auth.oauth_proxy` module is deprecated " + "and will be removed in a future version. " + "Please use `fastmcp.server.auth.oauth_dcr_proxy.OAuthDCRProxy` " + "instead of this module's OAuthProxy.", + DeprecationWarning, + stacklevel=2, ) - detail_box = create_detail_box(detail_rows) - - # Build form with buttons - form = f""" -
- - -
- - -
-
- """ - - # Build help link with tooltip - help_link = """ - - """ - - # Build the page content - content = f""" -
- {create_logo(icon_url=server_icon_url, alt_text=server_name or "FastMCP")} -

Authorization Consent

- {warning_box} - {detail_box} - {form} -
- {help_link} - """ - - # Additional styles needed for this page - additional_styles = ( - INFO_BOX_STYLES + DETAIL_BOX_STYLES + BUTTON_STYLES + TOOLTIP_STYLES - ) - - # Need to allow form-action for form submission - csp_policy = "default-src 'none'; style-src 'unsafe-inline'; img-src https:; base-uri 'none'; form-action *" - - return create_page( - content=content, - title=title, - additional_styles=additional_styles, - csp_policy=csp_policy, - ) - - -# ------------------------------------------------------------------------- -# Handler Classes -# ------------------------------------------------------------------------- - - -class TokenHandler(_SDKTokenHandler): - """TokenHandler that returns OAuth 2.1 compliant error responses. - - The MCP SDK always returns HTTP 400 for all client authentication issues. - However, OAuth 2.1 Section 5.3 and the MCP specification require that - invalid or expired tokens MUST receive a HTTP 401 response. - - This handler extends the base MCP SDK TokenHandler to transform client - authentication failures into OAuth 2.1 compliant responses: - - Changes 'unauthorized_client' to 'invalid_client' error code - - Returns HTTP 401 status code instead of 400 for client auth failures - - Per OAuth 2.1 Section 5.3: "The authorization server MAY return an HTTP 401 - (Unauthorized) status code to indicate which HTTP authentication schemes - are supported." - - Per MCP spec: "Invalid or expired tokens MUST receive a HTTP 401 response." - """ - - def response(self, obj: TokenSuccessResponse | TokenErrorResponse): - """Override response method to provide OAuth 2.1 compliant error handling.""" - # Check if this is a client authentication failure (not just unauthorized for grant type) - # unauthorized_client can mean two things: - # 1. Client authentication failed (client_id not found or wrong credentials) -> invalid_client 401 - # 2. Client not authorized for this grant type -> unauthorized_client 400 (correct per spec) - if ( - isinstance(obj, TokenErrorResponse) - and obj.error == "unauthorized_client" - and obj.error_description - and "Invalid client_id" in obj.error_description - ): - # Transform client auth failure to OAuth 2.1 compliant response - return PydanticJSONResponse( - content=TokenErrorResponse( - error="invalid_client", - error_description=obj.error_description, - error_uri=obj.error_uri, - ), - status_code=401, - headers={ - "Cache-Control": "no-store", - "Pragma": "no-cache", - }, - ) - - # Otherwise use default behavior from parent class - return super().response(obj) - - -class OAuthProxy(OAuthProvider): - """OAuth provider that presents a DCR-compliant interface while proxying to non-DCR IDPs. - - Purpose - ------- - MCP clients expect OAuth providers to support Dynamic Client Registration (DCR), - where clients can register themselves dynamically and receive unique credentials. - Most enterprise IDPs (Google, GitHub, Azure AD, etc.) don't support DCR and require - pre-registered OAuth applications with fixed credentials. - - This proxy bridges that gap by: - - Presenting a full DCR-compliant OAuth interface to MCP clients - - Translating DCR registration requests to use pre-configured upstream credentials - - Proxying all OAuth flows to the upstream IDP with appropriate translations - - Managing the state and security requirements of both protocols - - Architecture Overview - -------------------- - The proxy maintains a single OAuth app registration with the upstream provider - while allowing unlimited MCP clients to register and authenticate dynamically. - It implements the complete OAuth 2.1 + DCR specification for clients while - translating to whatever OAuth variant the upstream provider requires. - - Key Translation Challenges Solved - --------------------------------- - 1. Dynamic Client Registration: - - MCP clients expect to register dynamically and get unique credentials - - Upstream IDPs require pre-registered apps with fixed credentials - - Solution: Accept DCR requests, return shared upstream credentials - - 2. Dynamic Redirect URIs: - - MCP clients use random localhost ports that change between sessions - - Upstream IDPs require fixed, pre-registered redirect URIs - - Solution: Use proxy's fixed callback URL with upstream, forward to client's dynamic URI - - 3. Authorization Code Mapping: - - Upstream returns codes for the proxy's redirect URI - - Clients expect codes for their own redirect URIs - - Solution: Exchange upstream code server-side, issue new code to client - - 4. State Parameter Collision: - - Both client and proxy need to maintain state through the flow - - Only one state parameter available in OAuth - - Solution: Use transaction ID as state with upstream, preserve client's state - - 5. Token Management: - - Clients may expect different token formats/claims than upstream provides - - Need to track tokens for revocation and refresh - - Solution: Store token relationships, forward upstream tokens transparently - - OAuth Flow Implementation - ------------------------ - 1. Client Registration (DCR): - - Accept any client registration request - - Store ProxyDCRClient that accepts dynamic redirect URIs - - 2. Authorization: - - Store transaction mapping client details to proxy flow - - Redirect to upstream with proxy's fixed redirect URI - - Use transaction ID as state parameter with upstream - - 3. Upstream Callback: - - Exchange upstream authorization code for tokens (server-side) - - Generate new authorization code bound to client's PKCE challenge - - Redirect to client's original dynamic redirect URI - - 4. Token Exchange: - - Validate client's code and PKCE verifier - - Return previously obtained upstream tokens - - Clean up one-time use authorization code - - 5. Token Refresh: - - Forward refresh requests to upstream using authlib - - Handle token rotation if upstream issues new refresh token - - Update local token mappings - - State Management - --------------- - The proxy maintains minimal but crucial state: - - _oauth_transactions: Active authorization flows with client context - - _client_codes: Authorization codes with PKCE challenges and upstream tokens - - _access_tokens, _refresh_tokens: Token storage for revocation - - Token relationship mappings for cleanup and rotation - - Security Considerations - ---------------------- - - PKCE enforced end-to-end (client to proxy, proxy to upstream) - - Authorization codes are single-use with short expiry - - Transaction IDs are cryptographically random - - All state is cleaned up after use to prevent replay - - Token validation delegates to upstream provider - - Provider Compatibility - --------------------- - Works with any OAuth 2.0 provider that supports: - - Authorization code flow - - Fixed redirect URI (configured in provider's app settings) - - Standard token endpoint - - Handles provider-specific requirements: - - Google: Ensures minimum scope requirements - - GitHub: Compatible with OAuth Apps and GitHub Apps - - Azure AD: Handles tenant-specific endpoints - - Generic: Works with any spec-compliant provider - """ - - def __init__( - self, - *, - # Upstream server configuration - upstream_authorization_endpoint: str, - upstream_token_endpoint: str, - upstream_client_id: str, - upstream_client_secret: str, - upstream_revocation_endpoint: str | None = None, - # Token validation - token_verifier: TokenVerifier, - # FastMCP server configuration - base_url: AnyHttpUrl | str, - redirect_path: str | None = None, - issuer_url: AnyHttpUrl | str | None = None, - service_documentation_url: AnyHttpUrl | str | None = None, - # Client redirect URI validation - allowed_client_redirect_uris: list[str] | None = None, - valid_scopes: list[str] | None = None, - # PKCE configuration - forward_pkce: bool = True, - # Token endpoint authentication - token_endpoint_auth_method: str | None = None, - # Extra parameters to forward to authorization endpoint - extra_authorize_params: dict[str, str] | None = None, - # Extra parameters to forward to token endpoint - extra_token_params: dict[str, str] | None = None, - # Client storage - client_storage: AsyncKeyValue | None = None, - # JWT signing key (optional, ephemeral if not provided) - jwt_signing_key: str | bytes | None = None, - # Token encryption key (optional, ephemeral if not provided) - token_encryption_key: str | bytes | None = None, - ): - """Initialize the OAuth proxy provider. - - Args: - upstream_authorization_endpoint: URL of upstream authorization endpoint - upstream_token_endpoint: URL of upstream token endpoint - upstream_client_id: Client ID registered with upstream server - upstream_client_secret: Client secret for upstream server - upstream_revocation_endpoint: Optional upstream revocation endpoint - token_verifier: Token verifier for validating access tokens - base_url: Public URL of the server that exposes this FastMCP server; redirect path is - relative to this URL - redirect_path: Redirect path configured in upstream OAuth app (defaults to "/auth/callback") - issuer_url: Issuer URL for OAuth metadata (defaults to base_url) - service_documentation_url: Optional service documentation URL - allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. - Patterns support wildcards (e.g., "http://localhost:*", "https://*.example.com/*"). - If None (default), only localhost redirect URIs are allowed. - If empty list, all redirect URIs are allowed (not recommended for production). - These are for MCP clients performing loopback redirects, NOT for the upstream OAuth app. - valid_scopes: List of all the possible valid scopes for a client. - These are advertised to clients through the `/.well-known` endpoints. Defaults to `required_scopes` if not provided. - forward_pkce: Whether to forward PKCE to upstream server (default True). - Enable for providers that support/require PKCE (Google, Azure, AWS, etc.). - Disable only if upstream provider doesn't support PKCE. - token_endpoint_auth_method: Token endpoint authentication method for upstream server. - Common values: "client_secret_basic", "client_secret_post", "none". - If None, authlib will use its default (typically "client_secret_basic"). - extra_authorize_params: Additional parameters to forward to the upstream authorization endpoint. - Useful for provider-specific parameters like Auth0's "audience". - Example: {"audience": "https://api.example.com"} - extra_token_params: Additional parameters to forward to the upstream token endpoint. - Useful for provider-specific parameters during token exchange. - client_storage: An AsyncKeyValue-compatible store for client registrations, registrations are stored in memory if not provided - jwt_signing_key: Optional secret for signing FastMCP JWT tokens (accepts any string or bytes). - Default: ephemeral (random salt at startup, won't survive restart). - Production: provide explicit key from environment variable. - token_encryption_key: Optional secret for encrypting upstream tokens at rest (accepts any string or bytes). - Default: ephemeral (random salt at startup, won't survive restart). - Production: provide explicit key from environment variable. - """ - # Always enable DCR since we implement it locally for MCP clients - client_registration_options = ClientRegistrationOptions( - enabled=True, - valid_scopes=valid_scopes or token_verifier.required_scopes, - ) - - # Enable revocation only if upstream endpoint provided - revocation_options = ( - RevocationOptions(enabled=True) if upstream_revocation_endpoint else None - ) - - super().__init__( - base_url=base_url, - issuer_url=issuer_url, - service_documentation_url=service_documentation_url, - client_registration_options=client_registration_options, - revocation_options=revocation_options, - required_scopes=token_verifier.required_scopes, - ) - - # Store upstream configuration - self._upstream_authorization_endpoint = upstream_authorization_endpoint - self._upstream_token_endpoint = upstream_token_endpoint - self._upstream_client_id = upstream_client_id - self._upstream_client_secret = SecretStr(upstream_client_secret) - self._upstream_revocation_endpoint = upstream_revocation_endpoint - self._default_scope_str = " ".join(self.required_scopes or []) - - # Store redirect configuration - if not redirect_path: - self._redirect_path = "/auth/callback" - else: - self._redirect_path = ( - redirect_path if redirect_path.startswith("/") else f"/{redirect_path}" - ) - # Redirect URI validation (consent flow provides primary protection) - if allowed_client_redirect_uris is None: - logger.info( - "allowed_client_redirect_uris not specified; accepting all redirect URIs. " - "Consent flow provides protection against confused deputy attacks. " - "Configure allowed patterns for defense-in-depth." - ) - self._allowed_client_redirect_uris = None - elif ( - isinstance(allowed_client_redirect_uris, list) - and not allowed_client_redirect_uris - ): - logger.warning( - "allowed_client_redirect_uris is empty list; no redirect URIs will be accepted. " - "This will block all OAuth clients." - ) - self._allowed_client_redirect_uris = [] - else: - self._allowed_client_redirect_uris = allowed_client_redirect_uris - - # PKCE configuration - self._forward_pkce = forward_pkce - - # Token endpoint authentication - self._token_endpoint_auth_method = token_endpoint_auth_method - - # Extra parameters for authorization and token endpoints - self._extra_authorize_params = extra_authorize_params or {} - self._extra_token_params = extra_token_params or {} - - self._client_storage: AsyncKeyValue = client_storage or MemoryStore() - - # Warn if using MemoryStore in production - if isinstance(client_storage, MemoryStore): - logger.warning( - "Using in-memory storage - all OAuth state (clients, tokens) will be lost on restart. " - "Additionally, without explicit jwt_signing_key and token_encryption_key, " - "keys are ephemeral and tokens won't survive restart even with persistent storage. " - "For production, configure persistent storage AND explicit keys." - ) - - # Cache HTTPS check to avoid repeated logging - self._is_https = str(self.base_url).startswith("https://") - if not self._is_https: - logger.warning( - "Using non-secure cookies for development; deploy with HTTPS for production." - ) - - self._client_store = PydanticAdapter[ProxyDCRClient]( - key_value=self._client_storage, - pydantic_model=ProxyDCRClient, - default_collection="mcp-oauth-proxy-clients", - raise_on_validation_error=True, - ) - - # OAuth transaction storage for IdP callback forwarding - # Reuse client_storage with different collections for state management - self._transaction_store = PydanticAdapter[OAuthTransaction]( - key_value=self._client_storage, - pydantic_model=OAuthTransaction, - default_collection="mcp-oauth-transactions", - raise_on_validation_error=True, - ) - - self._code_store = PydanticAdapter[ClientCode]( - key_value=self._client_storage, - pydantic_model=ClientCode, - default_collection="mcp-authorization-codes", - raise_on_validation_error=True, - ) - - # Storage for upstream tokens (encrypted at rest) - self._upstream_token_store = PydanticAdapter[UpstreamTokenSet]( - key_value=self._client_storage, - pydantic_model=UpstreamTokenSet, - default_collection="mcp-upstream-tokens", - raise_on_validation_error=True, - ) - - # Storage for JTI mappings (FastMCP token -> upstream token) - self._jti_mapping_store = PydanticAdapter[JTIMapping]( - key_value=self._client_storage, - pydantic_model=JTIMapping, - default_collection="mcp-jti-mappings", - raise_on_validation_error=True, - ) - - # JWT issuer and encryption (initialized lazily on first use) - self._custom_jwt_key = jwt_signing_key - self._custom_encryption_key = token_encryption_key - self._jwt_issuer: JWTIssuer | None = None - self._token_encryption: TokenEncryption | None = None - self._jwt_initialized = False - - # Local state for token bookkeeping only (no client caching) - self._access_tokens: dict[str, AccessToken] = {} - self._refresh_tokens: dict[str, RefreshToken] = {} - - # Token relation mappings for cleanup - self._access_to_refresh: dict[str, str] = {} - self._refresh_to_access: dict[str, str] = {} - - # Use the provided token validator - self._token_validator = token_verifier - - logger.debug( - "Initialized OAuth proxy provider with upstream server %s", - self._upstream_authorization_endpoint, - ) - - # ------------------------------------------------------------------------- - # PKCE Helper Methods - # ------------------------------------------------------------------------- - - def _generate_pkce_pair(self) -> tuple[str, str]: - """Generate PKCE code verifier and challenge pair. - - Returns: - Tuple of (code_verifier, code_challenge) using S256 method - """ - # Generate code verifier: 43-128 characters from unreserved set - code_verifier = generate_token(48) - - # Generate code challenge using S256 (SHA256 + base64url) - challenge_bytes = hashlib.sha256(code_verifier.encode()).digest() - code_challenge = urlsafe_b64encode(challenge_bytes).decode().rstrip("=") - - return code_verifier, code_challenge - - # ------------------------------------------------------------------------- - # JWT Token Factory Initialization - # ------------------------------------------------------------------------- - - async def _ensure_jwt_initialized(self) -> None: - """Initialize JWT issuer and token encryption (lazy initialization). - - Key derivation strategy: - - Default: Generate random salt at startup, derive ephemeral keys - → Keys change on restart, all tokens become invalid - → Perfect for development/testing where re-auth is acceptable - - - Production: User provides explicit keys via parameters - → Keys stable across restarts when combined with persistent storage - → Tokens survive restart, seamless client reconnection - """ - if self._jwt_initialized: - return - - # Generate random salt for this server instance (NOT persisted) - server_salt = secrets.token_urlsafe(32) - - # Derive or use custom JWT signing key - from fastmcp.server.auth.jwt_issuer import derive_key_from_secret - - if self._custom_jwt_key: - jwt_key = derive_key_from_secret( - secret=self._custom_jwt_key, - salt="fastmcp-jwt-signing-v1", - info=b"HS256", - ) - logger.info("Using explicit JWT signing key (will survive restarts)") - else: - # Ephemeral key from random salt + upstream secret - upstream_secret = self._upstream_client_secret.get_secret_value() - jwt_key = derive_key_from_secret( - secret=upstream_secret, - salt=f"fastmcp-jwt-signing-v1-{server_salt}", - info=b"HS256", - ) - logger.info( - "Using ephemeral JWT signing key - tokens will NOT survive server restart. " - "For production, provide explicit jwt_signing_key parameter." - ) - - # Initialize JWT issuer - issuer = str(self.base_url) - audience = f"{str(self.base_url).rstrip('/')}/mcp" - self._jwt_issuer = JWTIssuer( - issuer=issuer, - audience=audience, - signing_key=jwt_key, - ) - - # Derive or use custom encryption key - if self._custom_encryption_key: - encryption_key = derive_key_from_secret( - secret=self._custom_encryption_key, - salt="fastmcp-token-encryption-v1", - info=b"Fernet", - ) - # Fernet needs base64url-encoded key - encryption_key = base64.urlsafe_b64encode(encryption_key) - logger.info("Using explicit token encryption key (will survive restarts)") - else: - # Ephemeral key from random salt + upstream secret - upstream_secret = self._upstream_client_secret.get_secret_value() - key_material = derive_key_from_secret( - secret=upstream_secret, - salt=f"fastmcp-token-encryption-v1-{server_salt}", - info=b"Fernet", - ) - encryption_key = base64.urlsafe_b64encode(key_material) - logger.info( - "Using ephemeral token encryption key - encrypted tokens will NOT survive server restart. " - "For production, provide explicit token_encryption_key parameter." - ) - - self._token_encryption = TokenEncryption(encryption_key) - self._jwt_initialized = True - - # ------------------------------------------------------------------------- - # Client Registration (Local Implementation) - # ------------------------------------------------------------------------- - - async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: - """Get client information by ID. This is generally the random ID - provided to the DCR client during registration, not the upstream client ID. - - For unregistered clients, returns None (which will raise an error in the SDK). - """ - # Load from storage - if not (client := await self._client_store.get(key=client_id)): - return None - - if client.allowed_redirect_uri_patterns is None: - client.allowed_redirect_uri_patterns = self._allowed_client_redirect_uris - - return client - - async def register_client(self, client_info: OAuthClientInformationFull) -> None: - """Register a client locally - - When a client registers, we create a ProxyDCRClient that is more - forgiving about validating redirect URIs, since the DCR client's - redirect URI will likely be localhost or unknown to the proxied IDP. The - proxied IDP only knows about this server's fixed redirect URI. - """ - - # Create a ProxyDCRClient with configured redirect URI validation - proxy_client: ProxyDCRClient = ProxyDCRClient( - client_id=client_info.client_id, - client_secret=client_info.client_secret, - redirect_uris=client_info.redirect_uris or [AnyUrl("http://localhost")], - grant_types=client_info.grant_types - or ["authorization_code", "refresh_token"], - scope=client_info.scope or self._default_scope_str, - token_endpoint_auth_method="none", - allowed_redirect_uri_patterns=self._allowed_client_redirect_uris, - client_name=getattr(client_info, "client_name", None), - ) - - await self._client_store.put( - key=client_info.client_id, - value=proxy_client, - ) - - # Log redirect URIs to help users discover what patterns they might need - if client_info.redirect_uris: - for uri in client_info.redirect_uris: - logger.debug( - "Client registered with redirect_uri: %s - if restricting redirect URIs, " - "ensure this pattern is allowed in allowed_client_redirect_uris", - uri, - ) - - logger.debug( - "Registered client %s with %d redirect URIs", - client_info.client_id, - len(proxy_client.redirect_uris), - ) - - # ------------------------------------------------------------------------- - # Authorization Flow (Proxy to Upstream) - # ------------------------------------------------------------------------- - - async def authorize( - self, - client: OAuthClientInformationFull, - params: AuthorizationParams, - ) -> str: - """Start OAuth transaction and route through consent interstitial. - - Flow: - 1. Store transaction with client details and PKCE (if forwarding) - 2. Return local /consent URL; browser visits consent first - 3. Consent handler redirects to upstream IdP if approved/already approved - """ - # Generate transaction ID for this authorization request - txn_id = secrets.token_urlsafe(32) - - # Generate proxy's own PKCE parameters if forwarding is enabled - proxy_code_verifier = None - proxy_code_challenge = None - if self._forward_pkce and params.code_challenge: - proxy_code_verifier, proxy_code_challenge = self._generate_pkce_pair() - logger.debug( - "Generated proxy PKCE for transaction %s (forwarding client PKCE to upstream)", - txn_id, - ) - - # Store transaction data for IdP callback processing - await self._transaction_store.put( - key=txn_id, - value=OAuthTransaction( - txn_id=txn_id, - client_id=client.client_id, - client_redirect_uri=str(params.redirect_uri), - client_state=params.state or "", - code_challenge=params.code_challenge, - code_challenge_method=getattr(params, "code_challenge_method", "S256"), - scopes=params.scopes or [], - created_at=time.time(), - resource=getattr(params, "resource", None), - proxy_code_verifier=proxy_code_verifier, - ), - ttl=15 * 60, # Auto-expire after 15 minutes - ) - - consent_url = f"{str(self.base_url).rstrip('/')}/consent?txn_id={txn_id}" - - logger.debug( - "Starting OAuth transaction %s for client %s, redirecting to consent page (PKCE forwarding: %s)", - txn_id, - client.client_id, - "enabled" if proxy_code_challenge else "disabled", - ) - return consent_url - - # ------------------------------------------------------------------------- - # Authorization Code Handling - # ------------------------------------------------------------------------- - - async def load_authorization_code( - self, - client: OAuthClientInformationFull, - authorization_code: str, - ) -> AuthorizationCode | None: - """Load authorization code for validation. - - Look up our client code and return authorization code object - with PKCE challenge for validation. - """ - # Look up client code data - code_model = await self._code_store.get(key=authorization_code) - if not code_model: - logger.debug("Authorization code not found: %s", authorization_code) - return None - - # Check if code expired - if time.time() > code_model.expires_at: - logger.debug("Authorization code expired: %s", authorization_code) - await self._code_store.delete(key=authorization_code) - return None - - # Verify client ID matches - if code_model.client_id != client.client_id: - logger.debug( - "Authorization code client ID mismatch: %s vs %s", - code_model.client_id, - client.client_id, - ) - return None - - # Create authorization code object with PKCE challenge - return AuthorizationCode( - code=authorization_code, - client_id=client.client_id, - redirect_uri=code_model.redirect_uri, - redirect_uri_provided_explicitly=True, - scopes=code_model.scopes, - expires_at=code_model.expires_at, - code_challenge=code_model.code_challenge or "", - ) - - async def exchange_authorization_code( - self, - client: OAuthClientInformationFull, - authorization_code: AuthorizationCode, - ) -> OAuthToken: - """Exchange authorization code for FastMCP-issued tokens. - - Implements the token factory pattern: - 1. Retrieves upstream tokens from stored authorization code - 2. Extracts user identity from upstream token - 3. Encrypts and stores upstream tokens - 4. Issues FastMCP-signed JWT tokens - 5. Returns FastMCP tokens (NOT upstream tokens) - - PKCE validation is handled by the MCP framework before this method is called. - """ - # Ensure JWT issuer is initialized - await self._ensure_jwt_initialized() - assert self._jwt_issuer is not None - assert self._token_encryption is not None - - # Look up stored code data - code_model = await self._code_store.get(key=authorization_code.code) - if not code_model: - logger.error( - "Authorization code not found in client codes: %s", - authorization_code.code, - ) - raise TokenError("invalid_grant", "Authorization code not found") - - # Get stored upstream tokens - idp_tokens = code_model.idp_tokens - - # Clean up client code (one-time use) - await self._code_store.delete(key=authorization_code.code) - - # Generate IDs for token storage - upstream_token_id = secrets.token_urlsafe(32) - access_jti = secrets.token_urlsafe(32) - refresh_jti = ( - secrets.token_urlsafe(32) if idp_tokens.get("refresh_token") else None - ) - - # Calculate token expiry times - expires_in = int( - idp_tokens.get("expires_in", DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS) - ) - - # Calculate refresh token expiry if provided by upstream - # Some providers include refresh_expires_in, some don't - refresh_expires_in = None - refresh_token_expires_at = None - if idp_tokens.get("refresh_token"): - if "refresh_expires_in" in idp_tokens: - refresh_expires_in = int(idp_tokens["refresh_expires_in"]) - refresh_token_expires_at = time.time() + refresh_expires_in - logger.debug( - "Upstream refresh token expires in %d seconds", refresh_expires_in - ) - else: - # Default to 30 days if upstream doesn't specify - # This is conservative - most providers use longer expiry - refresh_expires_in = 60 * 60 * 24 * 30 # 30 days - refresh_token_expires_at = time.time() + refresh_expires_in - logger.debug( - "Upstream refresh token expiry unknown, using 30-day default" - ) - - # Encrypt and store upstream tokens - upstream_token_set = UpstreamTokenSet( - upstream_token_id=upstream_token_id, - access_token=self._token_encryption.encrypt(idp_tokens["access_token"]), - refresh_token=self._token_encryption.encrypt(idp_tokens["refresh_token"]) - if idp_tokens.get("refresh_token") - else None, - refresh_token_expires_at=refresh_token_expires_at, - expires_at=time.time() + expires_in, - token_type=idp_tokens.get("token_type", "Bearer"), - scope=" ".join(authorization_code.scopes), - client_id=client.client_id, - created_at=time.time(), - raw_token_data=idp_tokens, - ) - await self._upstream_token_store.put( - key=upstream_token_id, - value=upstream_token_set, - ttl=expires_in, # Auto-expire when access token expires - ) - logger.debug("Stored encrypted upstream tokens (jti=%s)", access_jti[:8]) - - # Issue minimal FastMCP access token (just a reference via JTI) - fastmcp_access_token = self._jwt_issuer.issue_access_token( - client_id=client.client_id, - scopes=authorization_code.scopes, - jti=access_jti, - expires_in=expires_in, - ) - - # Issue minimal FastMCP refresh token if upstream provided one - # Use upstream refresh token expiry to align lifetimes - fastmcp_refresh_token = None - if refresh_jti and refresh_expires_in: - fastmcp_refresh_token = self._jwt_issuer.issue_refresh_token( - client_id=client.client_id, - scopes=authorization_code.scopes, - jti=refresh_jti, - expires_in=refresh_expires_in, - ) - - # Store JTI mappings - await self._jti_mapping_store.put( - key=access_jti, - value=JTIMapping( - jti=access_jti, - upstream_token_id=upstream_token_id, - created_at=time.time(), - ), - ttl=expires_in, # Auto-expire with access token - ) - if refresh_jti: - await self._jti_mapping_store.put( - key=refresh_jti, - value=JTIMapping( - jti=refresh_jti, - upstream_token_id=upstream_token_id, - created_at=time.time(), - ), - ttl=60 * 60 * 24 * 30, # Auto-expire with refresh token (30 days) - ) - - # Store FastMCP access token for MCP framework validation - self._access_tokens[fastmcp_access_token] = AccessToken( - token=fastmcp_access_token, - client_id=client.client_id, - scopes=authorization_code.scopes, - expires_at=int(time.time() + expires_in), - ) - - # Store FastMCP refresh token if provided - if fastmcp_refresh_token: - self._refresh_tokens[fastmcp_refresh_token] = RefreshToken( - token=fastmcp_refresh_token, - client_id=client.client_id, - scopes=authorization_code.scopes, - expires_at=None, - ) - # Maintain token relationships for cleanup - self._access_to_refresh[fastmcp_access_token] = fastmcp_refresh_token - self._refresh_to_access[fastmcp_refresh_token] = fastmcp_access_token - - logger.debug( - "Issued FastMCP tokens for client=%s (access_jti=%s, refresh_jti=%s)", - client.client_id, - access_jti[:8], - refresh_jti[:8] if refresh_jti else "none", - ) - - # Return FastMCP-issued tokens (NOT upstream tokens!) - return OAuthToken( - access_token=fastmcp_access_token, - token_type="Bearer", - expires_in=expires_in, - refresh_token=fastmcp_refresh_token, - scope=" ".join(authorization_code.scopes), - ) - - # ------------------------------------------------------------------------- - # Refresh Token Flow - # ------------------------------------------------------------------------- - - async def load_refresh_token( - self, - client: OAuthClientInformationFull, - refresh_token: str, - ) -> RefreshToken | None: - """Load refresh token from local storage.""" - return self._refresh_tokens.get(refresh_token) - - async def exchange_refresh_token( - self, - client: OAuthClientInformationFull, - refresh_token: RefreshToken, - scopes: list[str], - ) -> OAuthToken: - """Exchange FastMCP refresh token for new FastMCP access token. - - Implements two-tier refresh: - 1. Verify FastMCP refresh token - 2. Look up upstream token via JTI mapping - 3. Refresh upstream token with upstream provider - 4. Update stored upstream token - 5. Issue new FastMCP access token - 6. Keep same FastMCP refresh token (unless upstream rotates) - """ - # Ensure JWT issuer is initialized - await self._ensure_jwt_initialized() - assert self._jwt_issuer is not None - assert self._token_encryption is not None - - # Verify FastMCP refresh token - try: - refresh_payload = self._jwt_issuer.verify_token(refresh_token.token) - refresh_jti = refresh_payload["jti"] - except Exception as e: - logger.debug("FastMCP refresh token validation failed: %s", e) - raise TokenError("invalid_grant", "Invalid refresh token") from e - - # Look up upstream token via JTI mapping - jti_mapping = await self._jti_mapping_store.get(key=refresh_jti) - if not jti_mapping: - logger.error("JTI mapping not found for refresh token: %s", refresh_jti[:8]) - raise TokenError("invalid_grant", "Refresh token mapping not found") - - upstream_token_set = await self._upstream_token_store.get( - key=jti_mapping.upstream_token_id - ) - if not upstream_token_set: - logger.error( - "Upstream token set not found: %s", jti_mapping.upstream_token_id[:8] - ) - raise TokenError("invalid_grant", "Upstream token not found") - - # Decrypt upstream refresh token - if not upstream_token_set.refresh_token: - logger.error("No upstream refresh token available") - raise TokenError("invalid_grant", "Refresh not supported for this token") - - upstream_refresh_token = self._token_encryption.decrypt( - upstream_token_set.refresh_token - ) - - # Refresh upstream token using authlib - oauth_client = AsyncOAuth2Client( - client_id=self._upstream_client_id, - client_secret=self._upstream_client_secret.get_secret_value(), - token_endpoint_auth_method=self._token_endpoint_auth_method, - timeout=HTTP_TIMEOUT_SECONDS, - ) - - try: - logger.debug("Refreshing upstream token (jti=%s)", refresh_jti[:8]) - token_response: dict[str, Any] = await oauth_client.refresh_token( # type: ignore[misc] - url=self._upstream_token_endpoint, - refresh_token=upstream_refresh_token, - scope=" ".join(scopes) if scopes else None, - ) - logger.debug("Successfully refreshed upstream token") - except Exception as e: - logger.error("Upstream token refresh failed: %s", e) - raise TokenError("invalid_grant", f"Upstream refresh failed: {e}") from e - - # Update stored upstream token - new_expires_in = int( - token_response.get("expires_in", DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS) - ) - upstream_token_set.access_token = self._token_encryption.encrypt( - token_response["access_token"] - ) - upstream_token_set.expires_at = time.time() + new_expires_in - - # Handle upstream refresh token rotation and expiry - new_refresh_expires_in = None - if new_upstream_refresh := token_response.get("refresh_token"): - if new_upstream_refresh != upstream_refresh_token: - upstream_token_set.refresh_token = self._token_encryption.encrypt( - new_upstream_refresh - ) - logger.debug("Upstream refresh token rotated") - - # Update refresh token expiry if provided - if "refresh_expires_in" in token_response: - new_refresh_expires_in = int(token_response["refresh_expires_in"]) - upstream_token_set.refresh_token_expires_at = ( - time.time() + new_refresh_expires_in - ) - logger.debug( - "Upstream refresh token expires in %d seconds", - new_refresh_expires_in, - ) - elif upstream_token_set.refresh_token_expires_at: - # Keep existing expiry if upstream doesn't provide new one - new_refresh_expires_in = int( - upstream_token_set.refresh_token_expires_at - time.time() - ) - else: - # Default to 30 days if unknown - new_refresh_expires_in = 60 * 60 * 24 * 30 - upstream_token_set.refresh_token_expires_at = ( - time.time() + new_refresh_expires_in - ) - - upstream_token_set.raw_token_data = token_response - await self._upstream_token_store.put( - key=upstream_token_set.upstream_token_id, - value=upstream_token_set, - ttl=new_expires_in, # Auto-expire when refreshed access token expires - ) - - # Issue new minimal FastMCP access token (just a reference via JTI) - new_access_jti = secrets.token_urlsafe(32) - new_fastmcp_access = self._jwt_issuer.issue_access_token( - client_id=client.client_id, - scopes=scopes, - jti=new_access_jti, - expires_in=new_expires_in, - ) - - # Store new access token JTI mapping - await self._jti_mapping_store.put( - key=new_access_jti, - value=JTIMapping( - jti=new_access_jti, - upstream_token_id=upstream_token_set.upstream_token_id, - created_at=time.time(), - ), - ttl=new_expires_in, # Auto-expire with refreshed access token - ) - - # Issue NEW minimal FastMCP refresh token (rotation for security) - # Use upstream refresh token expiry to align lifetimes - new_refresh_jti = secrets.token_urlsafe(32) - new_fastmcp_refresh = self._jwt_issuer.issue_refresh_token( - client_id=client.client_id, - scopes=scopes, - jti=new_refresh_jti, - expires_in=new_refresh_expires_in - or 60 * 60 * 24 * 30, # Fallback to 30 days - ) - - # Store new refresh token JTI mapping with aligned expiry - refresh_ttl = new_refresh_expires_in or 60 * 60 * 24 * 30 - await self._jti_mapping_store.put( - key=new_refresh_jti, - value=JTIMapping( - jti=new_refresh_jti, - upstream_token_id=upstream_token_set.upstream_token_id, - created_at=time.time(), - ), - ttl=refresh_ttl, # Align with upstream refresh token expiry - ) - - # Invalidate old refresh token (refresh token rotation - enforces one-time use) - await self._jti_mapping_store.delete(key=refresh_jti) - logger.debug( - "Rotated refresh token (old JTI invalidated - one-time use enforced)" - ) - - # Update local token tracking - self._access_tokens[new_fastmcp_access] = AccessToken( - token=new_fastmcp_access, - client_id=client.client_id, - scopes=scopes, - expires_at=int(time.time() + new_expires_in), - ) - self._refresh_tokens[new_fastmcp_refresh] = RefreshToken( - token=new_fastmcp_refresh, - client_id=client.client_id, - scopes=scopes, - expires_at=None, - ) - - # Update token relationship mappings - self._access_to_refresh[new_fastmcp_access] = new_fastmcp_refresh - self._refresh_to_access[new_fastmcp_refresh] = new_fastmcp_access - - # Clean up old token from in-memory tracking - self._refresh_tokens.pop(refresh_token.token, None) - old_access = self._refresh_to_access.pop(refresh_token.token, None) - if old_access: - self._access_tokens.pop(old_access, None) - self._access_to_refresh.pop(old_access, None) - - logger.info( - "Issued new FastMCP tokens (rotated refresh) for client=%s (access_jti=%s, refresh_jti=%s)", - client.client_id, - new_access_jti[:8], - new_refresh_jti[:8], - ) - - # Return new FastMCP tokens (both access AND refresh are new) - return OAuthToken( - access_token=new_fastmcp_access, - token_type="Bearer", - expires_in=new_expires_in, - refresh_token=new_fastmcp_refresh, # NEW refresh token (rotated) - scope=" ".join(scopes), - ) - - # ------------------------------------------------------------------------- - # Token Validation - # ------------------------------------------------------------------------- - - async def load_access_token(self, token: str) -> AccessToken | None: - """Validate FastMCP JWT by swapping for upstream token. - - This implements the token swap pattern: - 1. Verify FastMCP JWT signature (proves it's our token) - 2. Look up upstream token via JTI mapping - 3. Decrypt upstream token - 4. Validate upstream token with provider (GitHub API, JWT validation, etc.) - 5. Return upstream validation result - - The FastMCP JWT is a reference token - all authorization data comes - from validating the upstream token via the TokenVerifier. - """ - # Ensure JWT issuer and encryption are initialized - await self._ensure_jwt_initialized() - assert self._jwt_issuer is not None - assert self._token_encryption is not None - - try: - # 1. Verify FastMCP JWT signature and claims - payload = self._jwt_issuer.verify_token(token) - jti = payload["jti"] - - # 2. Look up upstream token via JTI mapping - jti_mapping = await self._jti_mapping_store.get(key=jti) - if not jti_mapping: - logger.debug("JTI mapping not found: %s", jti) - return None - - upstream_token_set = await self._upstream_token_store.get( - key=jti_mapping.upstream_token_id - ) - if not upstream_token_set: - logger.debug( - "Upstream token not found: %s", jti_mapping.upstream_token_id - ) - return None - - # 3. Decrypt upstream token - upstream_token = self._token_encryption.decrypt( - upstream_token_set.access_token - ) - - # 4. Validate with upstream provider (delegated to TokenVerifier) - # This calls the real token validator (GitHub API, JWKS, etc.) - validated = await self._token_validator.verify_token(upstream_token) - - if not validated: - logger.debug("Upstream token validation failed") - return None - - logger.debug( - "Token swap successful for JTI=%s (upstream validated)", jti[:8] - ) - return validated - - except Exception as e: - logger.debug("Token swap validation failed: %s", e) - return None - - # ------------------------------------------------------------------------- - # Token Revocation - # ------------------------------------------------------------------------- - - async def revoke_token(self, token: AccessToken | RefreshToken) -> None: - """Revoke token locally and with upstream server if supported. - - Removes tokens from local storage and attempts to revoke them with - the upstream server if a revocation endpoint is configured. - """ - # Clean up local token storage - if isinstance(token, AccessToken): - self._access_tokens.pop(token.token, None) - # Also remove associated refresh token - paired_refresh = self._access_to_refresh.pop(token.token, None) - if paired_refresh: - self._refresh_tokens.pop(paired_refresh, None) - self._refresh_to_access.pop(paired_refresh, None) - else: # RefreshToken - self._refresh_tokens.pop(token.token, None) - # Also remove associated access token - paired_access = self._refresh_to_access.pop(token.token, None) - if paired_access: - self._access_tokens.pop(paired_access, None) - self._access_to_refresh.pop(paired_access, None) - - # Attempt upstream revocation if endpoint is configured - if self._upstream_revocation_endpoint: - try: - async with httpx.AsyncClient( - timeout=HTTP_TIMEOUT_SECONDS - ) as http_client: - await http_client.post( - self._upstream_revocation_endpoint, - data={"token": token.token}, - auth=( - self._upstream_client_id, - self._upstream_client_secret.get_secret_value(), - ), - ) - logger.debug("Successfully revoked token with upstream server") - except Exception as e: - logger.warning("Failed to revoke token with upstream server: %s", e) - else: - logger.debug("No upstream revocation endpoint configured") - - logger.debug("Token revoked successfully") - - def get_routes( - self, - mcp_path: str | None = None, - ) -> list[Route]: - """Get OAuth routes with custom proxy token handler. - - This method creates standard OAuth routes and replaces the token endpoint - with our proxy handler that forwards requests to the upstream OAuth server. - - Args: - mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") - This is used to advertise the resource URL in metadata. - """ - # Get standard OAuth routes from parent class - routes = super().get_routes(mcp_path) - custom_routes = [] - token_route_found = False - - logger.debug( - f"get_routes called - configuring OAuth routes in {len(routes)} routes" - ) - - for i, route in enumerate(routes): - logger.debug( - f"Route {i}: {route} - path: {getattr(route, 'path', 'N/A')}, methods: {getattr(route, 'methods', 'N/A')}" - ) - - # Replace the token endpoint with our custom handler that returns proper OAuth 2.1 error codes - if ( - isinstance(route, Route) - and route.path == "/token" - and route.methods is not None - and "POST" in route.methods - ): - token_route_found = True - # Replace with our OAuth 2.1 compliant token handler - token_handler = TokenHandler( - provider=self, client_authenticator=ClientAuthenticator(self) - ) - custom_routes.append( - Route( - path="/token", - endpoint=cors_middleware( - token_handler.handle, ["POST", "OPTIONS"] - ), - methods=["POST", "OPTIONS"], - ) - ) - else: - # Keep all other standard OAuth routes unchanged - custom_routes.append(route) - - # Add OAuth callback endpoint for forwarding to client callbacks - custom_routes.append( - Route( - path=self._redirect_path, - endpoint=self._handle_idp_callback, - methods=["GET"], - ) - ) - - # Add consent endpoints - custom_routes.append( - Route(path="/consent", endpoint=self._show_consent_page, methods=["GET"]) - ) - custom_routes.append( - Route( - path="/consent/submit", endpoint=self._submit_consent, methods=["POST"] - ) - ) - - logger.debug( - f"✅ OAuth routes configured: token_endpoint={token_route_found}, total routes={len(custom_routes)} (includes OAuth callback + consent)" - ) - return custom_routes - - # ------------------------------------------------------------------------- - # IdP Callback Forwarding - # ------------------------------------------------------------------------- - - async def _handle_idp_callback(self, request: Request) -> RedirectResponse: - """Handle callback from upstream IdP and forward to client. - - This implements the DCR-compliant callback forwarding: - 1. Receive IdP callback with code and txn_id as state - 2. Exchange IdP code for tokens (server-side) - 3. Generate our own client code bound to PKCE challenge - 4. Redirect to client's callback with client code and original state - """ - try: - idp_code = request.query_params.get("code") - txn_id = request.query_params.get("state") - error = request.query_params.get("error") - - if error: - logger.error( - "IdP callback error: %s - %s", - error, - request.query_params.get("error_description"), - ) - # TODO: Forward error to client callback - return RedirectResponse( - url=f"data:text/html,

OAuth Error

{error}: {request.query_params.get('error_description', 'Unknown error')}

", - status_code=302, - ) - - if not idp_code or not txn_id: - logger.error("IdP callback missing code or transaction ID") - return RedirectResponse( - url="data:text/html,

OAuth Error

Missing authorization code or transaction ID

", - status_code=302, - ) - - # Look up transaction data - transaction_model = await self._transaction_store.get(key=txn_id) - if not transaction_model: - logger.error("IdP callback with invalid transaction ID: %s", txn_id) - return RedirectResponse( - url="data:text/html,

OAuth Error

Invalid or expired transaction

", - status_code=302, - ) - transaction = transaction_model.model_dump() - - # Exchange IdP code for tokens (server-side) - oauth_client = AsyncOAuth2Client( - client_id=self._upstream_client_id, - client_secret=self._upstream_client_secret.get_secret_value(), - token_endpoint_auth_method=self._token_endpoint_auth_method, - timeout=HTTP_TIMEOUT_SECONDS, - ) - - try: - idp_redirect_uri = ( - f"{str(self.base_url).rstrip('/')}{self._redirect_path}" - ) - logger.debug( - f"Exchanging IdP code for tokens with redirect_uri: {idp_redirect_uri}" - ) - - # Build token exchange parameters - token_params = { - "url": self._upstream_token_endpoint, - "code": idp_code, - "redirect_uri": idp_redirect_uri, - } - - # Include proxy's code_verifier if we forwarded PKCE - proxy_code_verifier = transaction.get("proxy_code_verifier") - if proxy_code_verifier: - token_params["code_verifier"] = proxy_code_verifier - logger.debug( - "Including proxy code_verifier in token exchange for transaction %s", - txn_id, - ) - - # Add any extra token parameters configured for this proxy - if self._extra_token_params: - token_params.update(self._extra_token_params) - logger.debug( - "Adding extra token parameters for transaction %s: %s", - txn_id, - list(self._extra_token_params.keys()), - ) - - idp_tokens: dict[str, Any] = await oauth_client.fetch_token( - **token_params - ) # type: ignore[misc] - - logger.debug( - f"Successfully exchanged IdP code for tokens (transaction: {txn_id}, PKCE: {bool(proxy_code_verifier)})" - ) - - except Exception as e: - logger.error("IdP token exchange failed: %s", e) - # TODO: Forward error to client callback - return RedirectResponse( - url=f"data:text/html,

OAuth Error

Token exchange failed: {e}

", - status_code=302, - ) - - # Generate our own authorization code for the client - client_code = secrets.token_urlsafe(32) - code_expires_at = int(time.time() + DEFAULT_AUTH_CODE_EXPIRY_SECONDS) - - # Store client code with PKCE challenge and IdP tokens - await self._code_store.put( - key=client_code, - value=ClientCode( - code=client_code, - client_id=transaction["client_id"], - redirect_uri=transaction["client_redirect_uri"], - code_challenge=transaction["code_challenge"], - code_challenge_method=transaction["code_challenge_method"], - scopes=transaction["scopes"], - idp_tokens=idp_tokens, - expires_at=code_expires_at, - created_at=time.time(), - ), - ttl=DEFAULT_AUTH_CODE_EXPIRY_SECONDS, # Auto-expire after 5 minutes - ) - - # Clean up transaction - await self._transaction_store.delete(key=txn_id) - - # Build client callback URL with our code and original state - client_redirect_uri = transaction["client_redirect_uri"] - client_state = transaction["client_state"] - - callback_params = { - "code": client_code, - "state": client_state, - } - - # Add query parameters to client redirect URI - separator = "&" if "?" in client_redirect_uri else "?" - client_callback_url = ( - f"{client_redirect_uri}{separator}{urlencode(callback_params)}" - ) - - logger.debug(f"Forwarding to client callback for transaction {txn_id}") - - return RedirectResponse(url=client_callback_url, status_code=302) - - except Exception as e: - logger.error("Error in IdP callback handler: %s", e, exc_info=True) - return RedirectResponse( - url="data:text/html,

OAuth Error

Internal server error during IdP callback

", - status_code=302, - ) - - # ------------------------------------------------------------------------- - # Consent Interstitial - # ------------------------------------------------------------------------- - - def _normalize_uri(self, uri: str) -> str: - """Normalize a URI to a canonical form for consent tracking.""" - parsed = urlparse(uri) - path = parsed.path or "" - normalized = f"{parsed.scheme.lower()}://{parsed.netloc.lower()}{path}" - if normalized.endswith("/") and len(path) > 1: - normalized = normalized[:-1] - return normalized - - def _make_client_key(self, client_id: str, redirect_uri: str | AnyUrl) -> str: - """Create a stable key for consent tracking from client_id and redirect_uri.""" - normalized = self._normalize_uri(str(redirect_uri)) - return f"{client_id}:{normalized}" - - def _cookie_name(self, base_name: str) -> str: - """Return secure cookie name for HTTPS, fallback for HTTP development.""" - if self._is_https: - return f"__Host-{base_name}" - return f"__{base_name}" - - def _sign_cookie(self, payload: str) -> str: - """Sign a cookie payload with HMAC-SHA256. - - Returns: base64(payload).base64(signature) - """ - # Use upstream client secret as signing key - key = self._upstream_client_secret.get_secret_value().encode() - signature = hmac.new(key, payload.encode(), hashlib.sha256).digest() - signature_b64 = base64.b64encode(signature).decode() - return f"{payload}.{signature_b64}" - - def _verify_cookie(self, signed_value: str) -> str | None: - """Verify and extract payload from signed cookie. - - Returns: payload if signature valid, None otherwise - """ - try: - if "." not in signed_value: - return None - payload, signature_b64 = signed_value.rsplit(".", 1) - - # Verify signature - key = self._upstream_client_secret.get_secret_value().encode() - expected_sig = hmac.new(key, payload.encode(), hashlib.sha256).digest() - provided_sig = base64.b64decode(signature_b64.encode()) - - # Constant-time comparison - if not hmac.compare_digest(expected_sig, provided_sig): - return None - - return payload - except Exception: - return None - - def _decode_list_cookie(self, request: Request, base_name: str) -> list[str]: - """Decode and verify a signed base64-encoded JSON list from cookie. Returns [] if missing/invalid.""" - # Prefer secure name, but also check non-secure variant for dev - secure_name = self._cookie_name(base_name) - raw = request.cookies.get(secure_name) or request.cookies.get(f"__{base_name}") - if not raw: - return [] - try: - # Verify signature - payload = self._verify_cookie(raw) - if not payload: - logger.debug("Cookie signature verification failed for %s", secure_name) - return [] - - # Decode payload - data = base64.b64decode(payload.encode()) - value = json.loads(data.decode()) - if isinstance(value, list): - return [str(x) for x in value] - except Exception: - logger.debug("Failed to decode cookie %s; treating as empty", secure_name) - return [] - - def _encode_list_cookie(self, values: list[str]) -> str: - """Encode values to base64 and sign with HMAC. - - Returns: signed cookie value (payload.signature) - """ - payload = json.dumps(values, separators=(",", ":")).encode() - payload_b64 = base64.b64encode(payload).decode() - return self._sign_cookie(payload_b64) - - def _set_list_cookie( - self, - response: HTMLResponse | RedirectResponse, - base_name: str, - value_b64: str, - max_age: int, - ) -> None: - name = self._cookie_name(base_name) - response.set_cookie( - name, - value_b64, - max_age=max_age, - secure=self._is_https, - httponly=True, - samesite="lax", - path="/", - ) - - def _build_upstream_authorize_url( - self, txn_id: str, transaction: dict[str, Any] - ) -> str: - """Construct the upstream IdP authorization URL using stored transaction data.""" - query_params: dict[str, Any] = { - "response_type": "code", - "client_id": self._upstream_client_id, - "redirect_uri": f"{str(self.base_url).rstrip('/')}{self._redirect_path}", - "state": txn_id, - } - - scopes_to_use = transaction.get("scopes") or self.required_scopes or [] - if scopes_to_use: - query_params["scope"] = " ".join(scopes_to_use) - - # If PKCE forwarding was enabled, include the proxy challenge - proxy_code_verifier = transaction.get("proxy_code_verifier") - if proxy_code_verifier: - challenge_bytes = hashlib.sha256(proxy_code_verifier.encode()).digest() - proxy_code_challenge = ( - urlsafe_b64encode(challenge_bytes).decode().rstrip("=") - ) - query_params["code_challenge"] = proxy_code_challenge - query_params["code_challenge_method"] = "S256" - - # Forward resource indicator if present in transaction - if resource := transaction.get("resource"): - query_params["resource"] = resource - - # Extra configured parameters - if self._extra_authorize_params: - query_params.update(self._extra_authorize_params) - - separator = "&" if "?" in self._upstream_authorization_endpoint else "?" - return f"{self._upstream_authorization_endpoint}{separator}{urlencode(query_params)}" - - async def _show_consent_page( - self, request: Request - ) -> HTMLResponse | RedirectResponse: - """Display consent page or auto-approve/deny based on cookies.""" - from fastmcp.server.server import FastMCP - - txn_id = request.query_params.get("txn_id") - if not txn_id: - return create_secure_html_response( - "

Error

Invalid or expired transaction

", status_code=400 - ) - - txn_model = await self._transaction_store.get(key=txn_id) - if not txn_model: - return create_secure_html_response( - "

Error

Invalid or expired transaction

", status_code=400 - ) - - txn = txn_model.model_dump() - client_key = self._make_client_key(txn["client_id"], txn["client_redirect_uri"]) - - approved = set(self._decode_list_cookie(request, "MCP_APPROVED_CLIENTS")) - denied = set(self._decode_list_cookie(request, "MCP_DENIED_CLIENTS")) - - if client_key in approved: - upstream_url = self._build_upstream_authorize_url(txn_id, txn) - return RedirectResponse(url=upstream_url, status_code=302) - - if client_key in denied: - callback_params = { - "error": "access_denied", - "state": txn.get("client_state") or "", - } - sep = "&" if "?" in txn["client_redirect_uri"] else "?" - return RedirectResponse( - url=f"{txn['client_redirect_uri']}{sep}{urlencode(callback_params)}", - status_code=302, - ) - - # Need consent: issue CSRF token and show HTML - csrf_token = secrets.token_urlsafe(32) - csrf_expires_at = time.time() + 15 * 60 - - # Update transaction with CSRF token - txn_model.csrf_token = csrf_token - txn_model.csrf_expires_at = csrf_expires_at - await self._transaction_store.put( - key=txn_id, value=txn_model, ttl=15 * 60 - ) # Auto-expire after 15 minutes - - # Update dict for use in HTML generation - txn["csrf_token"] = csrf_token - txn["csrf_expires_at"] = csrf_expires_at - - # Load client to get client_name if available - client = await self.get_client(txn["client_id"]) - client_name = getattr(client, "client_name", None) if client else None - - # Extract server metadata from app state - fastmcp = getattr(request.app.state, "fastmcp_server", None) - - if isinstance(fastmcp, FastMCP): - server_name = fastmcp.name - icons = fastmcp.icons - server_icon_url = icons[0].src if icons else None - server_website_url = fastmcp.website_url - else: - server_name = None - server_icon_url = None - server_website_url = None - - html = create_consent_html( - client_id=txn["client_id"], - redirect_uri=txn["client_redirect_uri"], - scopes=txn.get("scopes") or [], - txn_id=txn_id, - csrf_token=csrf_token, - client_name=client_name, - server_name=server_name, - server_icon_url=server_icon_url, - server_website_url=server_website_url, - ) - response = create_secure_html_response(html) - # Store CSRF in cookie with short lifetime - self._set_list_cookie( - response, - "MCP_CONSENT_STATE", - self._encode_list_cookie([csrf_token]), - max_age=15 * 60, - ) - return response - - async def _submit_consent( - self, request: Request - ) -> RedirectResponse | HTMLResponse: - """Handle consent approval/denial, set cookies, and redirect appropriately.""" - form = await request.form() - txn_id = str(form.get("txn_id", "")) - action = str(form.get("action", "")) - csrf_token = str(form.get("csrf_token", "")) - - if not txn_id: - return create_secure_html_response( - "

Error

Invalid or expired transaction

", status_code=400 - ) - - txn_model = await self._transaction_store.get(key=txn_id) - if not txn_model: - return create_secure_html_response( - "

Error

Invalid or expired transaction

", status_code=400 - ) - - txn = txn_model.model_dump() - expected_csrf = txn.get("csrf_token") - expires_at = float(txn.get("csrf_expires_at") or 0) - - if not expected_csrf or csrf_token != expected_csrf or time.time() > expires_at: - return create_secure_html_response( - "

Error

Invalid or expired consent token

", status_code=400 - ) - - client_key = self._make_client_key(txn["client_id"], txn["client_redirect_uri"]) - - if action == "approve": - approved = set(self._decode_list_cookie(request, "MCP_APPROVED_CLIENTS")) - if client_key not in approved: - approved.add(client_key) - approved_b64 = self._encode_list_cookie(sorted(approved)) - - upstream_url = self._build_upstream_authorize_url(txn_id, txn) - response = RedirectResponse(url=upstream_url, status_code=302) - self._set_list_cookie( - response, "MCP_APPROVED_CLIENTS", approved_b64, max_age=365 * 24 * 3600 - ) - # Clear CSRF cookie by setting empty short-lived value - self._set_list_cookie( - response, "MCP_CONSENT_STATE", self._encode_list_cookie([]), max_age=60 - ) - return response - - elif action == "deny": - denied = set(self._decode_list_cookie(request, "MCP_DENIED_CLIENTS")) - if client_key not in denied: - denied.add(client_key) - denied_b64 = self._encode_list_cookie(sorted(denied)) - - callback_params = { - "error": "access_denied", - "state": txn.get("client_state") or "", - } - sep = "&" if "?" in txn["client_redirect_uri"] else "?" - client_callback_url = ( - f"{txn['client_redirect_uri']}{sep}{urlencode(callback_params)}" - ) - response = RedirectResponse(url=client_callback_url, status_code=302) - self._set_list_cookie( - response, "MCP_DENIED_CLIENTS", denied_b64, max_age=365 * 24 * 3600 - ) - self._set_list_cookie( - response, "MCP_CONSENT_STATE", self._encode_list_cookie([]), max_age=60 - ) - return response - - else: - return create_secure_html_response( - "

Error

Invalid action

", status_code=400 - ) diff --git a/src/fastmcp/server/auth/oidc_proxy.py b/src/fastmcp/server/auth/oidc_proxy.py index 6d5c82fe8..7084d1b02 100644 --- a/src/fastmcp/server/auth/oidc_proxy.py +++ b/src/fastmcp/server/auth/oidc_proxy.py @@ -17,7 +17,7 @@ from pydantic import AnyHttpUrl, BaseModel, model_validator from typing_extensions import Self from fastmcp.server.auth import TokenVerifier -from fastmcp.server.auth.oauth_proxy import OAuthProxy +from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy from fastmcp.server.auth.providers.jwt import JWTVerifier from fastmcp.utilities.logging import get_logger @@ -169,7 +169,7 @@ class OIDCConfiguration(BaseModel): raise -class OIDCProxy(OAuthProxy): +class OIDCProxy(OAuthDCRProxy): """OAuth provider that wraps OAuthProxy to provide configuration via an OIDC configuration URL. This provider makes it easier to add OAuth protection for any upstream provider diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index 93ad94182..1e1e69c32 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -12,7 +12,7 @@ from key_value.aio.protocols import AsyncKeyValue from pydantic import SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict -from fastmcp.server.auth.oauth_proxy import OAuthProxy +from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy from fastmcp.server.auth.providers.jwt import JWTVerifier from fastmcp.settings import ENV_FILE from fastmcp.utilities.auth import parse_scopes @@ -57,7 +57,7 @@ class AzureProviderSettings(BaseSettings): return parse_scopes(v) -class AzureProvider(OAuthProxy): +class AzureProvider(OAuthDCRProxy): """Azure (Microsoft Entra) OAuth provider for FastMCP. This provider implements Azure/Microsoft Entra ID authentication using the diff --git a/src/fastmcp/server/auth/providers/github.py b/src/fastmcp/server/auth/providers/github.py index d34bf041d..83ccd42d4 100644 --- a/src/fastmcp/server/auth/providers/github.py +++ b/src/fastmcp/server/auth/providers/github.py @@ -28,7 +28,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken -from fastmcp.server.auth.oauth_proxy import OAuthProxy +from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy from fastmcp.settings import ENV_FILE from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger @@ -166,7 +166,7 @@ class GitHubTokenVerifier(TokenVerifier): return None -class GitHubProvider(OAuthProxy): +class GitHubProvider(OAuthDCRProxy): """Complete GitHub OAuth provider for FastMCP. This provider makes it trivial to add GitHub OAuth protection to any diff --git a/src/fastmcp/server/auth/providers/google.py b/src/fastmcp/server/auth/providers/google.py index 1d925a3d0..8c2c89cfb 100644 --- a/src/fastmcp/server/auth/providers/google.py +++ b/src/fastmcp/server/auth/providers/google.py @@ -30,7 +30,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken -from fastmcp.server.auth.oauth_proxy import OAuthProxy +from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy from fastmcp.settings import ENV_FILE from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger @@ -182,7 +182,7 @@ class GoogleTokenVerifier(TokenVerifier): return None -class GoogleProvider(OAuthProxy): +class GoogleProvider(OAuthDCRProxy): """Complete Google OAuth provider for FastMCP. This provider makes it trivial to add Google OAuth protection to any diff --git a/src/fastmcp/server/auth/providers/workos.py b/src/fastmcp/server/auth/providers/workos.py index 52b6c62d2..bd88c5c98 100644 --- a/src/fastmcp/server/auth/providers/workos.py +++ b/src/fastmcp/server/auth/providers/workos.py @@ -18,7 +18,7 @@ from starlette.responses import JSONResponse from starlette.routing import Route from fastmcp.server.auth import AccessToken, RemoteAuthProvider, TokenVerifier -from fastmcp.server.auth.oauth_proxy import OAuthProxy +from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy from fastmcp.server.auth.providers.jwt import JWTVerifier from fastmcp.settings import ENV_FILE from fastmcp.utilities.auth import parse_scopes @@ -125,7 +125,7 @@ class WorkOSTokenVerifier(TokenVerifier): return None -class WorkOSProvider(OAuthProxy): +class WorkOSProvider(OAuthDCRProxy): """Complete WorkOS OAuth provider for FastMCP. This provider implements WorkOS AuthKit OAuth using the OAuth Proxy pattern. diff --git a/tests/integration_tests/auth/test_github_provider_integration.py b/tests/integration_tests/auth/test_github_provider_integration.py index b03798c62..b4071082a 100644 --- a/tests/integration_tests/auth/test_github_provider_integration.py +++ b/tests/integration_tests/auth/test_github_provider_integration.py @@ -82,7 +82,7 @@ def create_github_server_with_mock_callback(base_url: str) -> FastMCP: import secrets import time - from fastmcp.server.auth.oauth_proxy import ClientCode + from fastmcp.server.auth.oauth_dcr_proxy import ClientCode # Generate a fake authorization code fake_code = secrets.token_urlsafe(32) diff --git a/tests/server/auth/oauth_dcr_proxy/__init__.py b/tests/server/auth/oauth_dcr_proxy/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/server/auth/test_oauth_consent_flow.py b/tests/server/auth/oauth_dcr_proxy/test_oauth_consent_flow.py similarity index 98% rename from tests/server/auth/test_oauth_consent_flow.py rename to tests/server/auth/oauth_dcr_proxy/test_oauth_consent_flow.py index 648bcf990..e44fd53f8 100644 --- a/tests/server/auth/test_oauth_consent_flow.py +++ b/tests/server/auth/oauth_dcr_proxy/test_oauth_consent_flow.py @@ -25,7 +25,7 @@ from starlette.applications import Starlette from starlette.testclient import TestClient from fastmcp.server.auth.auth import TokenVerifier -from fastmcp.server.auth.oauth_proxy import OAuthProxy +from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy class MockTokenVerifier(TokenVerifier): @@ -69,7 +69,7 @@ def storage(): @pytest.fixture def oauth_proxy_with_storage(storage): """Create OAuth proxy with explicit storage backend.""" - return OAuthProxy( + return OAuthDCRProxy( upstream_authorization_endpoint="https://github.com/login/oauth/authorize", upstream_token_endpoint="https://github.com/login/oauth/access_token", upstream_client_id="test-upstream-client", @@ -84,7 +84,7 @@ def oauth_proxy_with_storage(storage): @pytest.fixture def oauth_proxy_https(): """OAuthProxy configured with HTTPS base_url for __Host- cookies.""" - return OAuthProxy( + return OAuthDCRProxy( upstream_authorization_endpoint="https://github.com/login/oauth/authorize", upstream_token_endpoint="https://github.com/login/oauth/access_token", upstream_client_id="client-id", @@ -96,7 +96,7 @@ def oauth_proxy_https(): async def _start_flow( - proxy: OAuthProxy, client_id: str, redirect: str + proxy: OAuthDCRProxy, client_id: str, redirect: str ) -> tuple[str, str]: """Register client and start auth; returns (txn_id, consent_url).""" await proxy.register_client( @@ -503,7 +503,7 @@ class TestStoragePersistence: async def test_storage_uses_pydantic_adapter(self, oauth_proxy_with_storage): """Verify that PydanticAdapter serializes/deserializes correctly.""" - from fastmcp.server.auth.oauth_proxy import OAuthTransaction + from fastmcp.server.auth.oauth_dcr_proxy import OAuthTransaction client = OAuthClientInformationFull( client_id="pydantic-test-client", @@ -674,7 +674,7 @@ class TestConsentPageServerIcon: verifier.verify_token = Mock(return_value=None) # Create OAuthProxy - proxy = OAuthProxy( + proxy = OAuthDCRProxy( upstream_authorization_endpoint="https://oauth.example.com/authorize", upstream_token_endpoint="https://oauth.example.com/token", upstream_client_id="upstream-client", @@ -705,7 +705,7 @@ class TestConsentPageServerIcon: await proxy.register_client(client_info) # Create a transaction manually - from fastmcp.server.auth.oauth_proxy import OAuthTransaction + from fastmcp.server.auth.oauth_dcr_proxy import OAuthTransaction txn_id = "test-txn-id" transaction = OAuthTransaction( @@ -745,7 +745,7 @@ class TestConsentPageServerIcon: verifier.verify_token = Mock(return_value=None) # Create OAuthProxy - proxy = OAuthProxy( + proxy = OAuthDCRProxy( upstream_authorization_endpoint="https://oauth.example.com/authorize", upstream_token_endpoint="https://oauth.example.com/token", upstream_client_id="upstream-client", @@ -769,7 +769,7 @@ class TestConsentPageServerIcon: await proxy.register_client(client_info) # Create a transaction - from fastmcp.server.auth.oauth_proxy import OAuthTransaction + from fastmcp.server.auth.oauth_dcr_proxy import OAuthTransaction txn_id = "test-txn-id" transaction = OAuthTransaction( @@ -811,7 +811,7 @@ class TestConsentPageServerIcon: verifier.verify_token = Mock(return_value=None) # Create OAuthProxy - proxy = OAuthProxy( + proxy = OAuthDCRProxy( upstream_authorization_endpoint="https://oauth.example.com/authorize", upstream_token_endpoint="https://oauth.example.com/token", upstream_client_id="upstream-client", @@ -839,7 +839,7 @@ class TestConsentPageServerIcon: await proxy.register_client(client_info) # Create a transaction - from fastmcp.server.auth.oauth_proxy import OAuthTransaction + from fastmcp.server.auth.oauth_dcr_proxy import OAuthTransaction txn_id = "test-txn-id" transaction = OAuthTransaction( diff --git a/tests/server/auth/oauth_dcr_proxy/test_oauth_proxy.py b/tests/server/auth/oauth_dcr_proxy/test_oauth_proxy.py new file mode 100644 index 000000000..89b4ac63b --- /dev/null +++ b/tests/server/auth/oauth_dcr_proxy/test_oauth_proxy.py @@ -0,0 +1,1297 @@ +"""Comprehensive tests for OAuth Proxy Provider functionality. + +This test suite covers: +1. Initialization and configuration +2. Client registration (DCR) +3. Authorization flow +4. Token management +5. PKCE forwarding +6. Token endpoint authentication methods +7. E2E testing with mock OAuth provider +""" + +import asyncio +import secrets +import time +from unittest.mock import AsyncMock, Mock, patch +from urllib.parse import parse_qs, urlencode, urlparse + +import httpx +import pytest +from mcp.server.auth.provider import AuthorizationParams +from mcp.shared.auth import OAuthClientInformationFull +from pydantic import AnyUrl +from starlette.applications import Starlette +from starlette.responses import JSONResponse +from starlette.routing import Route + +from fastmcp import FastMCP +from fastmcp.server.auth.auth import AccessToken, RefreshToken, TokenVerifier +from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy +from fastmcp.server.auth.providers.jwt import JWTVerifier + +# ============================================================================= +# Mock OAuth Provider for E2E Testing +# ============================================================================= + + +class MockOAuthProvider: + """Mock OAuth provider for testing OAuth proxy E2E flows. + + This provider simulates a complete OAuth server without requiring: + - Real authentication credentials + - Browser automation + - Network calls to external services + """ + + def __init__(self, port: int = 0): + self.port = port + self.base_url = f"http://localhost:{port}" + self.app = None + self.server = None + + # Storage for OAuth state + self.authorization_codes = {} + self.access_tokens = {} + self.refresh_tokens = {} + self.revoked_tokens = set() + + # Tracking for assertions + self.authorize_called = False + self.token_called = False + self.refresh_called = False + self.revoke_called = False + + # Configuration + self.require_pkce = False + self.token_endpoint_auth_method = "client_secret_basic" + + @property + def authorize_endpoint(self) -> str: + return f"{self.base_url}/authorize" + + @property + def token_endpoint(self) -> str: + return f"{self.base_url}/token" + + @property + def revocation_endpoint(self) -> str: + return f"{self.base_url}/revoke" + + def create_app(self) -> Starlette: + """Create the mock OAuth server application.""" + return Starlette( + routes=[ + Route("/authorize", self.handle_authorize), + Route("/token", self.handle_token, methods=["POST"]), + Route("/revoke", self.handle_revoke, methods=["POST"]), + ] + ) + + async def handle_authorize(self, request): + """Handle authorization requests.""" + self.authorize_called = True + query = dict(request.query_params) + + # Validate PKCE if required + if self.require_pkce and "code_challenge" not in query: + return JSONResponse( + {"error": "invalid_request", "error_description": "PKCE required"}, + status_code=400, + ) + + # Generate authorization code + code = secrets.token_urlsafe(32) + self.authorization_codes[code] = { + "client_id": query.get("client_id"), + "redirect_uri": query.get("redirect_uri"), + "state": query.get("state"), + "code_challenge": query.get("code_challenge"), + "code_challenge_method": query.get("code_challenge_method", "S256"), + "scope": query.get("scope"), + "created_at": time.time(), + } + + # Redirect back to callback + redirect_uri = query["redirect_uri"] + params = {"code": code} + if query.get("state"): + params["state"] = query["state"] + + redirect_url = f"{redirect_uri}?{urlencode(params)}" + return JSONResponse( + content={}, status_code=302, headers={"Location": redirect_url} + ) + + async def handle_token(self, request): + """Handle token requests.""" + self.token_called = True + form = await request.form() + grant_type = form.get("grant_type") + + if grant_type == "authorization_code": + code = form.get("code") + if code not in self.authorization_codes: + return JSONResponse( + {"error": "invalid_grant", "error_description": "Invalid code"}, + status_code=400, + ) + + # Validate PKCE if it was used + auth_data = self.authorization_codes[code] + if auth_data.get("code_challenge"): + verifier = form.get("code_verifier") + if not verifier: + return JSONResponse( + { + "error": "invalid_request", + "error_description": "Missing code_verifier", + }, + status_code=400, + ) + # In a real implementation, we'd validate the verifier + + # Generate tokens + access_token = f"mock_access_{secrets.token_hex(16)}" + refresh_token = f"mock_refresh_{secrets.token_hex(16)}" + + self.access_tokens[access_token] = { + "client_id": auth_data["client_id"], + "scope": auth_data.get("scope"), + "expires_at": time.time() + 3600, + } + self.refresh_tokens[refresh_token] = { + "client_id": auth_data["client_id"], + "scope": auth_data.get("scope"), + } + + # Clean up used code + del self.authorization_codes[code] + + return JSONResponse( + { + "access_token": access_token, + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": refresh_token, + "scope": auth_data.get("scope"), + } + ) + + elif grant_type == "refresh_token": + self.refresh_called = True + refresh_token = form.get("refresh_token") + + if refresh_token not in self.refresh_tokens: + return JSONResponse( + { + "error": "invalid_grant", + "error_description": "Invalid refresh token", + }, + status_code=400, + ) + + # Generate new access token + new_access = f"mock_access_{secrets.token_hex(16)}" + token_data = self.refresh_tokens[refresh_token] + + self.access_tokens[new_access] = { + "client_id": token_data["client_id"], + "scope": token_data.get("scope"), + "expires_at": time.time() + 3600, + } + + return JSONResponse( + { + "access_token": new_access, + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": refresh_token, # Same refresh token + "scope": token_data.get("scope"), + } + ) + + return JSONResponse({"error": "unsupported_grant_type"}, status_code=400) + + async def handle_revoke(self, request): + """Handle token revocation.""" + self.revoke_called = True + form = await request.form() + token = form.get("token") + + if token: + self.revoked_tokens.add(token) + # Remove from active tokens + self.access_tokens.pop(token, None) + self.refresh_tokens.pop(token, None) + + return JSONResponse({}) + + async def start(self): + """Start the mock OAuth server.""" + import socket + + from uvicorn import Config, Server + + self.app = self.create_app() + + # If port is 0, find an available port + if self.port == 0: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + s.listen(1) + self.port = s.getsockname()[1] + + self.base_url = f"http://localhost:{self.port}" + config = Config( + self.app, + host="localhost", + port=self.port, + log_level="error", + ws="websockets-sansio", + ) + self.server = Server(config) + + # Start server in background + asyncio.create_task(self.server.serve()) + + # Wait for server to be ready + await asyncio.sleep(0.05) + + async def stop(self): + """Stop the mock OAuth server.""" + if self.server: + self.server.should_exit = True + await asyncio.sleep(0.01) + + def reset(self): + """Reset all state for next test.""" + self.authorization_codes.clear() + self.access_tokens.clear() + self.refresh_tokens.clear() + self.revoked_tokens.clear() + self.authorize_called = False + self.token_called = False + self.refresh_called = False + self.revoke_called = False + + +class MockTokenVerifier(TokenVerifier): + """Mock token verifier for testing.""" + + def __init__(self, required_scopes=None): + self.required_scopes = required_scopes or ["read", "write"] + self.verify_called = False + + async def verify_token(self, token: str) -> AccessToken: + """Mock token verification.""" + self.verify_called = True + return AccessToken( + token=token, + client_id="mock-client", + scopes=self.required_scopes, + expires_at=int(time.time() + 3600), + ) + + +# ============================================================================= +# Test Fixtures +# ============================================================================= + + +@pytest.fixture +def jwt_verifier(): + """Create a mock JWT verifier for testing.""" + verifier = Mock(spec=JWTVerifier) + verifier.required_scopes = ["read", "write"] + verifier.verify_token = Mock(return_value=None) + return verifier + + +@pytest.fixture +def oauth_proxy(jwt_verifier): + """Create a standard OAuthProxy instance for testing.""" + return OAuthDCRProxy( + upstream_authorization_endpoint="https://github.com/login/oauth/authorize", + upstream_token_endpoint="https://github.com/login/oauth/access_token", + upstream_client_id="test-client-id", + upstream_client_secret="test-client-secret", + token_verifier=jwt_verifier, + base_url="https://myserver.com", + redirect_path="/auth/callback", + ) + + +@pytest.fixture +async def mock_oauth_provider(): + """Create and start a mock OAuth provider.""" + provider = MockOAuthProvider() + await provider.start() + yield provider + await provider.stop() + + +# ============================================================================= +# Test Classes +# ============================================================================= + + +class TestOAuthProxyInitialization: + """Tests for OAuth proxy initialization and configuration.""" + + def test_basic_initialization(self, jwt_verifier): + """Test basic proxy initialization with required parameters.""" + proxy = OAuthDCRProxy( + upstream_authorization_endpoint="https://auth.example.com/authorize", + upstream_token_endpoint="https://auth.example.com/token", + upstream_client_id="client-123", + upstream_client_secret="secret-456", + token_verifier=jwt_verifier, + base_url="https://api.example.com", + ) + + assert ( + proxy._upstream_authorization_endpoint + == "https://auth.example.com/authorize" + ) + assert proxy._upstream_token_endpoint == "https://auth.example.com/token" + assert proxy._upstream_client_id == "client-123" + assert proxy._upstream_client_secret.get_secret_value() == "secret-456" + assert str(proxy.base_url) == "https://api.example.com/" + + def test_all_optional_parameters(self, jwt_verifier): + """Test initialization with all optional parameters.""" + proxy = OAuthDCRProxy( + upstream_authorization_endpoint="https://auth.example.com/authorize", + upstream_token_endpoint="https://auth.example.com/token", + upstream_client_id="client-123", + upstream_client_secret="secret-456", + upstream_revocation_endpoint="https://auth.example.com/revoke", + token_verifier=jwt_verifier, + base_url="https://api.example.com", + redirect_path="/custom/callback", + issuer_url="https://issuer.example.com", + service_documentation_url="https://docs.example.com", + allowed_client_redirect_uris=["http://localhost:*"], + valid_scopes=["custom", "scopes"], + forward_pkce=False, + token_endpoint_auth_method="client_secret_post", + ) + + assert proxy._upstream_revocation_endpoint == "https://auth.example.com/revoke" + assert proxy._redirect_path == "/custom/callback" + assert proxy._forward_pkce is False + assert proxy._token_endpoint_auth_method == "client_secret_post" + assert proxy.client_registration_options is not None + assert proxy.client_registration_options.valid_scopes == ["custom", "scopes"] + + def test_redirect_path_normalization(self, jwt_verifier): + """Test that redirect_path is normalized with leading slash.""" + proxy = OAuthDCRProxy( + upstream_authorization_endpoint="https://auth.com/authorize", + upstream_token_endpoint="https://auth.com/token", + upstream_client_id="client", + upstream_client_secret="secret", + token_verifier=jwt_verifier, + base_url="https://api.com", + redirect_path="auth/callback", # No leading slash + ) + assert proxy._redirect_path == "/auth/callback" + + +class TestOAuthProxyClientRegistration: + """Tests for OAuth proxy client registration (DCR).""" + + async def test_register_client(self, oauth_proxy): + """Test client registration creates ProxyDCRClient.""" + client_info = OAuthClientInformationFull( + client_id="original-client", + client_secret="original-secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + + await oauth_proxy.register_client(client_info) + + # Client should be retrievable with original credentials + stored = await oauth_proxy.get_client("original-client") + assert stored is not None + assert stored.client_id == "original-client" + assert stored.client_secret == "original-secret" + + async def test_get_registered_client(self, oauth_proxy): + """Test retrieving a registered client.""" + client_info = OAuthClientInformationFull( + client_id="test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:8080/callback")], + ) + await oauth_proxy.register_client(client_info) + + retrieved = await oauth_proxy.get_client("test-client") + assert retrieved is not None + assert retrieved.client_id == "test-client" + + async def test_get_unregistered_client_returns_none(self, oauth_proxy): + """Test that unregistered clients return None.""" + client = await oauth_proxy.get_client("unknown-client") + assert client is None + + +class TestOAuthProxyAuthorization: + """Tests for OAuth proxy authorization flow.""" + + async def test_authorize_creates_transaction(self, oauth_proxy): + """Test that authorize creates transaction and redirects to consent.""" + client = OAuthClientInformationFull( + client_id="test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:54321/callback")], + ) + + # Register client first (required for consent flow) + await oauth_proxy.register_client(client) + + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:54321/callback"), + redirect_uri_provided_explicitly=True, + state="client-state-123", + code_challenge="challenge-abc", + code_challenge_method="S256", + scopes=["read", "write"], + ) + + redirect_url = await oauth_proxy.authorize(client, params) + + # Parse the redirect URL + parsed = urlparse(redirect_url) + query_params = parse_qs(parsed.query) + + # Should redirect to consent page + assert "/consent" in redirect_url + assert "txn_id" in query_params + + # Verify transaction was stored with correct data + txn_id = query_params["txn_id"][0] + transaction = await oauth_proxy._transaction_store.get(key=txn_id) + assert transaction is not None + assert transaction.client_id == "test-client" + assert transaction.code_challenge == "challenge-abc" + assert transaction.client_state == "client-state-123" + assert transaction.scopes == ["read", "write"] + + +class TestOAuthProxyPKCE: + """Tests for OAuth proxy PKCE forwarding.""" + + @pytest.fixture + def proxy_with_pkce(self, jwt_verifier): + return OAuthDCRProxy( + upstream_authorization_endpoint="https://oauth.example.com/authorize", + upstream_token_endpoint="https://oauth.example.com/token", + upstream_client_id="upstream-client", + upstream_client_secret="upstream-secret", + token_verifier=jwt_verifier, + base_url="https://proxy.example.com", + forward_pkce=True, + ) + + @pytest.fixture + def proxy_without_pkce(self, jwt_verifier): + return OAuthDCRProxy( + upstream_authorization_endpoint="https://oauth.example.com/authorize", + upstream_token_endpoint="https://oauth.example.com/token", + upstream_client_id="upstream-client", + upstream_client_secret="upstream-secret", + token_verifier=jwt_verifier, + base_url="https://proxy.example.com", + forward_pkce=False, + ) + + async def test_pkce_forwarding_enabled(self, proxy_with_pkce): + """Test that proxy generates and forwards its own PKCE.""" + client = OAuthClientInformationFull( + client_id="test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + + # Register client first + await proxy_with_pkce.register_client(client) + + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:12345/callback"), + redirect_uri_provided_explicitly=True, + state="client-state", + code_challenge="client_challenge", + scopes=["read"], + ) + + redirect_url = await proxy_with_pkce.authorize(client, params) + query_params = parse_qs(urlparse(redirect_url).query) + + # Should redirect to consent page + assert "/consent" in redirect_url + assert "txn_id" in query_params + + # Transaction should store both challenges + txn_id = query_params["txn_id"][0] + transaction = await proxy_with_pkce._transaction_store.get(key=txn_id) + assert transaction is not None + assert transaction.code_challenge == "client_challenge" # Client's + assert transaction.proxy_code_verifier is not None # Proxy's verifier + # Proxy code challenge is computed from verifier when building upstream URL + # Just verify the verifier exists and is different from client's challenge + assert len(transaction.proxy_code_verifier) > 0 + + async def test_pkce_forwarding_disabled(self, proxy_without_pkce): + """Test that PKCE is not forwarded when disabled.""" + client = OAuthClientInformationFull( + client_id="test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + + # Register client first + await proxy_without_pkce.register_client(client) + + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:12345/callback"), + redirect_uri_provided_explicitly=True, + state="client-state", + code_challenge="client_challenge", + scopes=["read"], + ) + + redirect_url = await proxy_without_pkce.authorize(client, params) + query_params = parse_qs(urlparse(redirect_url).query) + + # Should redirect to consent page + assert "/consent" in redirect_url + assert "txn_id" in query_params + + # Client's challenge still stored, but no proxy PKCE + txn_id = query_params["txn_id"][0] + transaction = await proxy_without_pkce._transaction_store.get(key=txn_id) + assert transaction is not None + assert transaction.code_challenge == "client_challenge" + assert transaction.proxy_code_verifier is None # No proxy PKCE when disabled + + +class TestOAuthProxyTokenEndpointAuth: + """Tests for token endpoint authentication methods.""" + + def test_token_auth_method_initialization(self, jwt_verifier): + """Test different token endpoint auth methods.""" + # client_secret_post + proxy_post = OAuthDCRProxy( + upstream_authorization_endpoint="https://oauth.example.com/authorize", + upstream_token_endpoint="https://oauth.example.com/token", + upstream_client_id="client", + upstream_client_secret="secret", + token_verifier=jwt_verifier, + base_url="https://proxy.example.com", + token_endpoint_auth_method="client_secret_post", + ) + assert proxy_post._token_endpoint_auth_method == "client_secret_post" + + # client_secret_basic (default) + proxy_basic = OAuthDCRProxy( + upstream_authorization_endpoint="https://oauth.example.com/authorize", + upstream_token_endpoint="https://oauth.example.com/token", + upstream_client_id="client", + upstream_client_secret="secret", + token_verifier=jwt_verifier, + base_url="https://proxy.example.com", + token_endpoint_auth_method="client_secret_basic", + ) + assert proxy_basic._token_endpoint_auth_method == "client_secret_basic" + + # None (use authlib default) + proxy_default = OAuthDCRProxy( + upstream_authorization_endpoint="https://oauth.example.com/authorize", + upstream_token_endpoint="https://oauth.example.com/token", + upstream_client_id="client", + upstream_client_secret="secret", + token_verifier=jwt_verifier, + base_url="https://proxy.example.com", + ) + assert proxy_default._token_endpoint_auth_method is None + + async def test_token_auth_method_passed_to_client(self, jwt_verifier): + """Test that auth method is passed to AsyncOAuth2Client.""" + proxy = OAuthDCRProxy( + upstream_authorization_endpoint="https://oauth.example.com/authorize", + upstream_token_endpoint="https://oauth.example.com/token", + upstream_client_id="client-id", + upstream_client_secret="client-secret", + token_verifier=jwt_verifier, + base_url="https://proxy.example.com", + token_endpoint_auth_method="client_secret_post", + ) + + # First, create a valid FastMCP token via full OAuth flow + client = OAuthClientInformationFull( + client_id="test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + + # Mock the upstream OAuth provider response + with patch( + "fastmcp.server.auth.oauth_dcr_proxy.AsyncOAuth2Client" + ) as MockClient: + mock_client = AsyncMock() + + # Mock initial token exchange (authorization code flow) + mock_client.fetch_token = AsyncMock( + return_value={ + "access_token": "upstream-access-token", + "refresh_token": "upstream-refresh-token", + "expires_in": 3600, + "token_type": "Bearer", + } + ) + + # Mock token refresh + mock_client.refresh_token = AsyncMock( + return_value={ + "access_token": "new-upstream-token", + "refresh_token": "new-upstream-refresh", + "expires_in": 3600, + "token_type": "Bearer", + } + ) + MockClient.return_value = mock_client + + # Register client and do initial OAuth flow to get valid FastMCP tokens + await proxy.register_client(client) + + # Store client code that would be created during OAuth callback + from fastmcp.server.auth.oauth_dcr_proxy import ClientCode + + client_code = ClientCode( + code="test-auth-code", + client_id="test-client", + redirect_uri="http://localhost:12345/callback", + code_challenge="", + code_challenge_method="S256", + scopes=["read"], + idp_tokens={ + "access_token": "upstream-access-token", + "refresh_token": "upstream-refresh-token", + "expires_in": 3600, + "token_type": "Bearer", + }, + expires_at=time.time() + 300, + created_at=time.time(), + ) + await proxy._code_store.put(key=client_code.code, value=client_code) + + # Exchange authorization code to get FastMCP tokens + from mcp.server.auth.provider import AuthorizationCode + + auth_code = AuthorizationCode( + code="test-auth-code", + scopes=["read"], + expires_at=time.time() + 300, + client_id="test-client", + code_challenge="", + redirect_uri=AnyUrl("http://localhost:12345/callback"), + redirect_uri_provided_explicitly=True, + ) + result = await proxy.exchange_authorization_code( + client=client, + authorization_code=auth_code, + ) + + # Now test refresh with the valid FastMCP refresh token + assert result.refresh_token is not None + fastmcp_refresh = RefreshToken( + token=result.refresh_token, + client_id="test-client", + scopes=["read"], + expires_at=None, + ) + + # Reset mock to check refresh call + MockClient.reset_mock() + mock_client.refresh_token = AsyncMock( + return_value={ + "access_token": "new-upstream-token-2", + "refresh_token": "new-upstream-refresh-2", + "expires_in": 3600, + "token_type": "Bearer", + } + ) + MockClient.return_value = mock_client + + await proxy.exchange_refresh_token(client, fastmcp_refresh, ["read"]) + + # Verify auth method was passed to OAuth client + MockClient.assert_called_with( + client_id="client-id", + client_secret="client-secret", + token_endpoint_auth_method="client_secret_post", + timeout=30.0, + ) + + +class TestOAuthProxyE2E: + """End-to-end tests using mock OAuth provider.""" + + async def test_full_oauth_flow_with_mock_provider(self, mock_oauth_provider): + """Test complete OAuth flow with mock provider.""" + # Create proxy pointing to mock provider + proxy = OAuthDCRProxy( + upstream_authorization_endpoint=mock_oauth_provider.authorize_endpoint, + upstream_token_endpoint=mock_oauth_provider.token_endpoint, + upstream_client_id="mock-client", + upstream_client_secret="mock-secret", + token_verifier=MockTokenVerifier(), + base_url="http://localhost:8000", + ) + + # Create FastMCP server with proxy + server = FastMCP("Test Server", auth=proxy) + + @server.tool + def protected_tool() -> str: + return "Protected data" + + # Start authorization flow + client_info = OAuthClientInformationFull( + client_id="test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + + # Register client first + await proxy.register_client(client_info) + + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:12345/callback"), + redirect_uri_provided_explicitly=True, + state="client-state", + code_challenge="", # Empty string for no PKCE + scopes=["read"], + ) + + # Get authorization URL (now returns consent redirect) + auth_url = await proxy.authorize(client_info, params) + + # Should redirect to consent page + assert "/consent" in auth_url + query_params = parse_qs(urlparse(auth_url).query) + assert "txn_id" in query_params + + # Verify transaction was created with correct configuration + txn_id = query_params["txn_id"][0] + transaction = await proxy._transaction_store.get(key=txn_id) + assert transaction is not None + assert transaction.client_id == "test-client" + assert transaction.scopes == ["read"] + # Transaction ID itself is used as upstream state parameter + assert transaction.txn_id == txn_id + + async def test_token_refresh_with_mock_provider(self, mock_oauth_provider): + """Test token refresh flow with mock provider.""" + proxy = OAuthDCRProxy( + upstream_authorization_endpoint=mock_oauth_provider.authorize_endpoint, + upstream_token_endpoint=mock_oauth_provider.token_endpoint, + upstream_client_id="mock-client", + upstream_client_secret="mock-secret", + token_verifier=MockTokenVerifier(), + base_url="http://localhost:8000", + ) + + client = OAuthClientInformationFull( + client_id="test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + + # Register client first + await proxy.register_client(client) + + # Set up initial upstream tokens in mock provider + upstream_refresh_token = "mock_refresh_initial" + mock_oauth_provider.refresh_tokens[upstream_refresh_token] = { + "client_id": "mock-client", + "scope": "read write", + } + + with patch( + "fastmcp.server.auth.oauth_dcr_proxy.AsyncOAuth2Client" + ) as MockClient: + mock_client = AsyncMock() + + # Mock initial token exchange to get FastMCP tokens + mock_client.fetch_token = AsyncMock( + return_value={ + "access_token": "upstream-access-initial", + "refresh_token": upstream_refresh_token, + "expires_in": 3600, + "token_type": "Bearer", + } + ) + + # Configure mock to call real provider for refresh + async def mock_refresh(*args, **kwargs): + async with httpx.AsyncClient() as http: + response = await http.post( + mock_oauth_provider.token_endpoint, + data={ + "grant_type": "refresh_token", + "refresh_token": upstream_refresh_token, + }, + ) + return response.json() + + mock_client.refresh_token = mock_refresh + MockClient.return_value = mock_client + + # Store client code that would be created during OAuth callback + from fastmcp.server.auth.oauth_dcr_proxy import ClientCode + + client_code = ClientCode( + code="test-auth-code", + client_id="test-client", + redirect_uri="http://localhost:12345/callback", + code_challenge="", + code_challenge_method="S256", + scopes=["read", "write"], + idp_tokens={ + "access_token": "upstream-access-initial", + "refresh_token": upstream_refresh_token, + "expires_in": 3600, + "token_type": "Bearer", + }, + expires_at=time.time() + 300, + created_at=time.time(), + ) + await proxy._code_store.put(key=client_code.code, value=client_code) + + # Exchange authorization code to get FastMCP tokens + from mcp.server.auth.provider import AuthorizationCode + + auth_code = AuthorizationCode( + code="test-auth-code", + scopes=["read", "write"], + expires_at=time.time() + 300, + client_id="test-client", + code_challenge="", + redirect_uri=AnyUrl("http://localhost:12345/callback"), + redirect_uri_provided_explicitly=True, + ) + initial_result = await proxy.exchange_authorization_code( + client=client, + authorization_code=auth_code, + ) + + # Now test refresh with the valid FastMCP refresh token + assert initial_result.refresh_token is not None + fastmcp_refresh = RefreshToken( + token=initial_result.refresh_token, + client_id="test-client", + scopes=["read"], + expires_at=None, + ) + + result = await proxy.exchange_refresh_token( + client, fastmcp_refresh, ["read"] + ) + + # Should return new FastMCP tokens (not upstream tokens) + assert result.access_token != "upstream-access-initial" + # FastMCP tokens are JWTs (have 3 segments) + assert len(result.access_token.split(".")) == 3 + assert mock_oauth_provider.refresh_called + + async def test_pkce_validation_with_mock_provider(self, mock_oauth_provider): + """Test PKCE validation with mock provider.""" + mock_oauth_provider.require_pkce = True + + proxy = OAuthDCRProxy( + upstream_authorization_endpoint=mock_oauth_provider.authorize_endpoint, + upstream_token_endpoint=mock_oauth_provider.token_endpoint, + upstream_client_id="mock-client", + upstream_client_secret="mock-secret", + token_verifier=MockTokenVerifier(), + base_url="http://localhost:8000", + forward_pkce=True, # Enable PKCE forwarding + ) + + client = OAuthClientInformationFull( + client_id="test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + + # Register client first + await proxy.register_client(client) + + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:12345/callback"), + redirect_uri_provided_explicitly=True, + state="client-state", + code_challenge="client_challenge_value", + code_challenge_method="S256", + scopes=["read"], + ) + + # Start authorization with PKCE + auth_url = await proxy.authorize(client, params) + query_params = parse_qs(urlparse(auth_url).query) + + # Should redirect to consent page + assert "/consent" in auth_url + assert "txn_id" in query_params + + # Transaction should have proxy's PKCE verifier (different from client's) + txn_id = query_params["txn_id"][0] + transaction = await proxy._transaction_store.get(key=txn_id) + assert transaction is not None + assert ( + transaction.code_challenge == "client_challenge_value" + ) # Client's challenge + assert transaction.proxy_code_verifier is not None # Proxy generated its own + # Proxy code challenge is computed from verifier when needed + assert len(transaction.proxy_code_verifier) > 0 + + +class TestParameterForwarding: + """Tests for forwarding custom parameters to upstream OAuth provider.""" + + @pytest.fixture + def proxy_with_extra_params(self, jwt_verifier): + """Create OAuthProxy with extra parameters configured.""" + return OAuthDCRProxy( + upstream_authorization_endpoint="https://oauth.example.com/authorize", + upstream_token_endpoint="https://oauth.example.com/token", + upstream_client_id="upstream-client", + upstream_client_secret="upstream-secret", + token_verifier=jwt_verifier, + base_url="https://proxy.example.com", + extra_authorize_params={"audience": "https://api.example.com"}, + extra_token_params={"audience": "https://api.example.com"}, + ) + + @pytest.fixture + def proxy_without_extra_params(self, jwt_verifier): + """Create OAuthProxy without extra parameters.""" + return OAuthDCRProxy( + upstream_authorization_endpoint="https://oauth.example.com/authorize", + upstream_token_endpoint="https://oauth.example.com/token", + upstream_client_id="upstream-client", + upstream_client_secret="upstream-secret", + token_verifier=jwt_verifier, + base_url="https://proxy.example.com", + ) + + async def test_resource_parameter_forwarding(self, proxy_without_extra_params): + """Test that RFC 8707 resource parameter is forwarded from client request.""" + client = OAuthClientInformationFull( + client_id="test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + + # Register client first + await proxy_without_extra_params.register_client(client) + + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:12345/callback"), + redirect_uri_provided_explicitly=True, + state="client-state", + code_challenge="client_challenge", + scopes=["read"], + resource="https://api.example.com/v1", # RFC 8707 resource indicator + ) + + redirect_url = await proxy_without_extra_params.authorize(client, params) + query_params = parse_qs(urlparse(redirect_url).query) + + # Should redirect to consent page + assert "/consent" in redirect_url + assert "txn_id" in query_params + + # Resource parameter should be stored in transaction for upstream forwarding + txn_id = query_params["txn_id"][0] + transaction = await proxy_without_extra_params._transaction_store.get( + key=txn_id + ) + assert transaction is not None + assert transaction.resource == "https://api.example.com/v1" + + async def test_extra_authorize_params(self, proxy_with_extra_params): + """Test that extra authorization parameters are included.""" + client = OAuthClientInformationFull( + client_id="test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + + # Register client first + await proxy_with_extra_params.register_client(client) + + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:12345/callback"), + redirect_uri_provided_explicitly=True, + state="client-state", + code_challenge="client_challenge", + scopes=["read"], + ) + + redirect_url = await proxy_with_extra_params.authorize(client, params) + query_params = parse_qs(urlparse(redirect_url).query) + + # Should redirect to consent page + assert "/consent" in redirect_url + assert "txn_id" in query_params + + # Extra audience parameter is configured at proxy level (not per-transaction) + txn_id = query_params["txn_id"][0] + transaction = await proxy_with_extra_params._transaction_store.get(key=txn_id) + assert transaction is not None + # Verify proxy has extra params configured + assert ( + proxy_with_extra_params._extra_authorize_params.get("audience") + == "https://api.example.com" + ) + + async def test_resource_and_extra_params_together(self, proxy_with_extra_params): + """Test that both resource and extra params can be used together.""" + client = OAuthClientInformationFull( + client_id="test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + + # Register client first + await proxy_with_extra_params.register_client(client) + + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:12345/callback"), + redirect_uri_provided_explicitly=True, + state="client-state", + code_challenge="client_challenge", + scopes=["read"], + resource="https://resource.example.com", # Client-specified resource + ) + + redirect_url = await proxy_with_extra_params.authorize(client, params) + query_params = parse_qs(urlparse(redirect_url).query) + + # Should redirect to consent page + assert "/consent" in redirect_url + assert "txn_id" in query_params + + # Resource stored in transaction, extra params configured at proxy level + txn_id = query_params["txn_id"][0] + transaction = await proxy_with_extra_params._transaction_store.get(key=txn_id) + assert transaction is not None + assert transaction.resource == "https://resource.example.com" + assert ( + proxy_with_extra_params._extra_authorize_params.get("audience") + == "https://api.example.com" + ) + + async def test_no_extra_params_when_not_configured( + self, proxy_without_extra_params + ): + """Test that no extra params are added when not configured.""" + client = OAuthClientInformationFull( + client_id="test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:12345/callback"), + redirect_uri_provided_explicitly=True, + state="client-state", + code_challenge="client_challenge", + scopes=["read"], + # No resource parameter + ) + + redirect_url = await proxy_without_extra_params.authorize(client, params) + query_params = parse_qs(urlparse(redirect_url).query) + + # No audience parameter should be present (not configured) + assert "audience" not in query_params + # No resource parameter should be present (not provided by client) + assert "resource" not in query_params + + async def test_multiple_extra_params(self, jwt_verifier): + """Test multiple extra parameters can be configured and forwarded.""" + proxy = OAuthDCRProxy( + upstream_authorization_endpoint="https://oauth.example.com/authorize", + upstream_token_endpoint="https://oauth.example.com/token", + upstream_client_id="upstream-client", + upstream_client_secret="upstream-secret", + token_verifier=jwt_verifier, + base_url="https://proxy.example.com", + extra_authorize_params={ + "audience": "https://api.example.com", + "prompt": "consent", + "max_age": "3600", + }, + ) + + client = OAuthClientInformationFull( + client_id="test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + + # Register client first + await proxy.register_client(client) + + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:12345/callback"), + redirect_uri_provided_explicitly=True, + state="client-state", + code_challenge="client_challenge", + scopes=["read"], + ) + + redirect_url = await proxy.authorize(client, params) + query_params = parse_qs(urlparse(redirect_url).query) + + # Should redirect to consent page + assert "/consent" in redirect_url + assert "txn_id" in query_params + + # All extra parameters configured at proxy level + txn_id = query_params["txn_id"][0] + transaction = await proxy._transaction_store.get(key=txn_id) + assert transaction is not None + # Verify proxy has all extra params configured + assert ( + proxy._extra_authorize_params.get("audience") == "https://api.example.com" + ) + assert proxy._extra_authorize_params.get("prompt") == "consent" + assert proxy._extra_authorize_params.get("max_age") == "3600" + + async def test_token_endpoint_invalid_client_error(self, jwt_verifier): + """Test that invalid client_id returns OAuth 2.1 compliant error response. + + When a client ID is not found during token exchange, the proxy should: + 1. Return HTTP 401 status code + 2. Use 'invalid_client' error code instead of 'unauthorized_client' + + This aligns with OAuth 2.1 spec and enables Claude's automatic client re-registration. + """ + from starlette.applications import Starlette + from starlette.testclient import TestClient + + proxy = OAuthDCRProxy( + upstream_authorization_endpoint="https://oauth.example.com/authorize", + upstream_token_endpoint="https://oauth.example.com/token", + upstream_client_id="upstream-client", + upstream_client_secret="upstream-secret", + token_verifier=jwt_verifier, + base_url="https://proxy.example.com", + ) + + # Create a test app with OAuth routes + app = Starlette(routes=proxy.get_routes()) + + # Test the token endpoint with an invalid (non-existent) client_id + with TestClient(app) as client: + response = client.post( + "/token", + data={ + "grant_type": "authorization_code", + "code": "test-auth-code", + "client_id": "non-existent-client-id", + "code_verifier": "test-code-verifier", + "redirect_uri": "http://localhost:12345/callback", + }, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + }, + ) + + # Verify OAuth 2.1 compliant error response + assert response.status_code == 401, ( + f"Expected 401 but got {response.status_code}" + ) + + error_data = response.json() + assert error_data["error"] == "invalid_client", ( + f"Expected 'invalid_client' but got '{error_data.get('error')}'" + ) + assert "Invalid client_id" in error_data["error_description"] + + # Verify proper cache headers are set + assert response.headers.get("Cache-Control") == "no-store" + assert response.headers.get("Pragma") == "no-cache" + + +class TestTokenHandlerErrorTransformation: + """Tests for TokenHandler's OAuth 2.1 compliant error transformation.""" + + def test_transforms_client_auth_failure_to_invalid_client_401(self): + """Test that client authentication failures return invalid_client with 401.""" + from mcp.server.auth.handlers.token import TokenErrorResponse + + from fastmcp.server.auth.oauth_dcr_proxy import TokenHandler + + handler = TokenHandler(provider=Mock(), client_authenticator=Mock()) + + # Simulate error from ClientAuthenticator.authenticate() failure + error_response = TokenErrorResponse( + error="unauthorized_client", + error_description="Invalid client_id 'test-client-id'", + ) + + response = handler.response(error_response) + + # Should transform to OAuth 2.1 compliant response + assert response.status_code == 401 + assert b'"error":"invalid_client"' in response.body + assert ( + b'"error_description":"Invalid client_id \'test-client-id\'"' + in response.body + ) + + def test_does_not_transform_grant_type_unauthorized_to_invalid_client(self): + """Test that grant type authorization errors stay as unauthorized_client with 400.""" + from mcp.server.auth.handlers.token import TokenErrorResponse + + from fastmcp.server.auth.oauth_dcr_proxy import TokenHandler + + handler = TokenHandler(provider=Mock(), client_authenticator=Mock()) + + # Simulate error from grant_type not in client_info.grant_types + error_response = TokenErrorResponse( + error="unauthorized_client", + error_description="Client not authorized for this grant type", + ) + + response = handler.response(error_response) + + # Should NOT transform - keep as 400 unauthorized_client + assert response.status_code == 400 + assert b'"error":"unauthorized_client"' in response.body + + def test_does_not_transform_other_errors(self): + """Test that other error types pass through unchanged.""" + from mcp.server.auth.handlers.token import TokenErrorResponse + + from fastmcp.server.auth.oauth_dcr_proxy import TokenHandler + + handler = TokenHandler(provider=Mock(), client_authenticator=Mock()) + + error_response = TokenErrorResponse( + error="invalid_grant", + error_description="Authorization code has expired", + ) + + response = handler.response(error_response) + + # Should pass through unchanged + assert response.status_code == 400 + assert b'"error":"invalid_grant"' in response.body diff --git a/tests/server/auth/test_oauth_proxy_redirect_validation.py b/tests/server/auth/oauth_dcr_proxy/test_oauth_proxy_redirect_validation.py similarity index 97% rename from tests/server/auth/test_oauth_proxy_redirect_validation.py rename to tests/server/auth/oauth_dcr_proxy/test_oauth_proxy_redirect_validation.py index 4ef54db67..f687f8237 100644 --- a/tests/server/auth/test_oauth_proxy_redirect_validation.py +++ b/tests/server/auth/oauth_dcr_proxy/test_oauth_proxy_redirect_validation.py @@ -5,7 +5,7 @@ from mcp.shared.auth import InvalidRedirectUriError from pydantic import AnyUrl from fastmcp.server.auth.auth import TokenVerifier -from fastmcp.server.auth.oauth_proxy import OAuthProxy, ProxyDCRClient +from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy, ProxyDCRClient class MockTokenVerifier(TokenVerifier): @@ -103,7 +103,7 @@ class TestOAuthProxyRedirectValidation: def test_proxy_default_allows_all(self): """Test that OAuth proxy defaults to allowing all URIs for DCR compatibility.""" - proxy = OAuthProxy( + proxy = OAuthDCRProxy( upstream_authorization_endpoint="https://auth.example.com/authorize", upstream_token_endpoint="https://auth.example.com/token", upstream_client_id="test-client", @@ -119,7 +119,7 @@ class TestOAuthProxyRedirectValidation: """Test OAuth proxy with custom redirect patterns.""" custom_patterns = ["http://localhost:*", "https://*.myapp.com/*"] - proxy = OAuthProxy( + proxy = OAuthDCRProxy( upstream_authorization_endpoint="https://auth.example.com/authorize", upstream_token_endpoint="https://auth.example.com/token", upstream_client_id="test-client", @@ -133,7 +133,7 @@ class TestOAuthProxyRedirectValidation: def test_proxy_empty_list_validation(self): """Test OAuth proxy with empty list (allow none).""" - proxy = OAuthProxy( + proxy = OAuthDCRProxy( upstream_authorization_endpoint="https://auth.example.com/authorize", upstream_token_endpoint="https://auth.example.com/token", upstream_client_id="test-client", @@ -149,7 +149,7 @@ class TestOAuthProxyRedirectValidation: """Test that registered clients use the configured patterns.""" custom_patterns = ["https://app.example.com/*"] - proxy = OAuthProxy( + proxy = OAuthDCRProxy( upstream_authorization_endpoint="https://auth.example.com/authorize", upstream_token_endpoint="https://auth.example.com/token", upstream_client_id="test-client", @@ -181,7 +181,7 @@ class TestOAuthProxyRedirectValidation: """Test that unregistered clients return None.""" custom_patterns = ["http://localhost:*", "http://127.0.0.1:*"] - proxy = OAuthProxy( + proxy = OAuthDCRProxy( upstream_authorization_endpoint="https://auth.example.com/authorize", upstream_token_endpoint="https://auth.example.com/token", upstream_client_id="test-client", diff --git a/tests/server/auth/test_oauth_proxy_storage.py b/tests/server/auth/oauth_dcr_proxy/test_oauth_proxy_storage.py similarity index 97% rename from tests/server/auth/test_oauth_proxy_storage.py rename to tests/server/auth/oauth_dcr_proxy/test_oauth_proxy_storage.py index 629708427..e2e57ad36 100644 --- a/tests/server/auth/test_oauth_proxy_storage.py +++ b/tests/server/auth/oauth_dcr_proxy/test_oauth_proxy_storage.py @@ -12,7 +12,7 @@ from key_value.aio.stores.memory import MemoryStore from mcp.shared.auth import OAuthClientInformationFull from pydantic import AnyUrl -from fastmcp.server.auth.oauth_proxy import OAuthProxy +from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy class TestOAuthProxyStorage: @@ -39,9 +39,9 @@ class TestOAuthProxyStorage: """Create in-memory storage for testing.""" return MemoryStore() - def create_proxy(self, jwt_verifier, storage=None) -> OAuthProxy: + def create_proxy(self, jwt_verifier, storage=None) -> OAuthDCRProxy: """Create an OAuth proxy with specified storage.""" - return OAuthProxy( + return OAuthDCRProxy( upstream_authorization_endpoint="https://github.com/login/oauth/authorize", upstream_token_endpoint="https://github.com/login/oauth/access_token", upstream_client_id="test-client-id", @@ -109,7 +109,7 @@ class TestOAuthProxyStorage: self, jwt_verifier, temp_storage ): """Test that ProxyDCRClient is created with redirect URI patterns.""" - proxy = OAuthProxy( + proxy = OAuthDCRProxy( upstream_authorization_endpoint="https://github.com/login/oauth/authorize", upstream_token_endpoint="https://github.com/login/oauth/access_token", upstream_client_id="test-client-id", diff --git a/tests/server/auth/test_oidc_proxy.py b/tests/server/auth/oauth_dcr_proxy/test_oidc_proxy.py similarity index 100% rename from tests/server/auth/test_oidc_proxy.py rename to tests/server/auth/oauth_dcr_proxy/test_oidc_proxy.py diff --git a/tests/server/auth/test_oauth_proxy.py b/tests/server/auth/test_oauth_proxy.py index 59cb0a38a..89b4ac63b 100644 --- a/tests/server/auth/test_oauth_proxy.py +++ b/tests/server/auth/test_oauth_proxy.py @@ -27,7 +27,7 @@ from starlette.routing import Route from fastmcp import FastMCP from fastmcp.server.auth.auth import AccessToken, RefreshToken, TokenVerifier -from fastmcp.server.auth.oauth_proxy import OAuthProxy +from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy from fastmcp.server.auth.providers.jwt import JWTVerifier # ============================================================================= @@ -311,7 +311,7 @@ def jwt_verifier(): @pytest.fixture def oauth_proxy(jwt_verifier): """Create a standard OAuthProxy instance for testing.""" - return OAuthProxy( + return OAuthDCRProxy( upstream_authorization_endpoint="https://github.com/login/oauth/authorize", upstream_token_endpoint="https://github.com/login/oauth/access_token", upstream_client_id="test-client-id", @@ -341,7 +341,7 @@ class TestOAuthProxyInitialization: def test_basic_initialization(self, jwt_verifier): """Test basic proxy initialization with required parameters.""" - proxy = OAuthProxy( + proxy = OAuthDCRProxy( upstream_authorization_endpoint="https://auth.example.com/authorize", upstream_token_endpoint="https://auth.example.com/token", upstream_client_id="client-123", @@ -361,7 +361,7 @@ class TestOAuthProxyInitialization: def test_all_optional_parameters(self, jwt_verifier): """Test initialization with all optional parameters.""" - proxy = OAuthProxy( + proxy = OAuthDCRProxy( upstream_authorization_endpoint="https://auth.example.com/authorize", upstream_token_endpoint="https://auth.example.com/token", upstream_client_id="client-123", @@ -387,7 +387,7 @@ class TestOAuthProxyInitialization: def test_redirect_path_normalization(self, jwt_verifier): """Test that redirect_path is normalized with leading slash.""" - proxy = OAuthProxy( + proxy = OAuthDCRProxy( upstream_authorization_endpoint="https://auth.com/authorize", upstream_token_endpoint="https://auth.com/token", upstream_client_id="client", @@ -485,7 +485,7 @@ class TestOAuthProxyPKCE: @pytest.fixture def proxy_with_pkce(self, jwt_verifier): - return OAuthProxy( + return OAuthDCRProxy( upstream_authorization_endpoint="https://oauth.example.com/authorize", upstream_token_endpoint="https://oauth.example.com/token", upstream_client_id="upstream-client", @@ -497,7 +497,7 @@ class TestOAuthProxyPKCE: @pytest.fixture def proxy_without_pkce(self, jwt_verifier): - return OAuthProxy( + return OAuthDCRProxy( upstream_authorization_endpoint="https://oauth.example.com/authorize", upstream_token_endpoint="https://oauth.example.com/token", upstream_client_id="upstream-client", @@ -583,7 +583,7 @@ class TestOAuthProxyTokenEndpointAuth: def test_token_auth_method_initialization(self, jwt_verifier): """Test different token endpoint auth methods.""" # client_secret_post - proxy_post = OAuthProxy( + proxy_post = OAuthDCRProxy( upstream_authorization_endpoint="https://oauth.example.com/authorize", upstream_token_endpoint="https://oauth.example.com/token", upstream_client_id="client", @@ -595,7 +595,7 @@ class TestOAuthProxyTokenEndpointAuth: assert proxy_post._token_endpoint_auth_method == "client_secret_post" # client_secret_basic (default) - proxy_basic = OAuthProxy( + proxy_basic = OAuthDCRProxy( upstream_authorization_endpoint="https://oauth.example.com/authorize", upstream_token_endpoint="https://oauth.example.com/token", upstream_client_id="client", @@ -607,7 +607,7 @@ class TestOAuthProxyTokenEndpointAuth: assert proxy_basic._token_endpoint_auth_method == "client_secret_basic" # None (use authlib default) - proxy_default = OAuthProxy( + proxy_default = OAuthDCRProxy( upstream_authorization_endpoint="https://oauth.example.com/authorize", upstream_token_endpoint="https://oauth.example.com/token", upstream_client_id="client", @@ -619,7 +619,7 @@ class TestOAuthProxyTokenEndpointAuth: async def test_token_auth_method_passed_to_client(self, jwt_verifier): """Test that auth method is passed to AsyncOAuth2Client.""" - proxy = OAuthProxy( + proxy = OAuthDCRProxy( upstream_authorization_endpoint="https://oauth.example.com/authorize", upstream_token_endpoint="https://oauth.example.com/token", upstream_client_id="client-id", @@ -637,7 +637,9 @@ class TestOAuthProxyTokenEndpointAuth: ) # Mock the upstream OAuth provider response - with patch("fastmcp.server.auth.oauth_proxy.AsyncOAuth2Client") as MockClient: + with patch( + "fastmcp.server.auth.oauth_dcr_proxy.AsyncOAuth2Client" + ) as MockClient: mock_client = AsyncMock() # Mock initial token exchange (authorization code flow) @@ -665,7 +667,7 @@ class TestOAuthProxyTokenEndpointAuth: await proxy.register_client(client) # Store client code that would be created during OAuth callback - from fastmcp.server.auth.oauth_proxy import ClientCode + from fastmcp.server.auth.oauth_dcr_proxy import ClientCode client_code = ClientCode( code="test-auth-code", @@ -740,7 +742,7 @@ class TestOAuthProxyE2E: async def test_full_oauth_flow_with_mock_provider(self, mock_oauth_provider): """Test complete OAuth flow with mock provider.""" # Create proxy pointing to mock provider - proxy = OAuthProxy( + proxy = OAuthDCRProxy( upstream_authorization_endpoint=mock_oauth_provider.authorize_endpoint, upstream_token_endpoint=mock_oauth_provider.token_endpoint, upstream_client_id="mock-client", @@ -793,7 +795,7 @@ class TestOAuthProxyE2E: async def test_token_refresh_with_mock_provider(self, mock_oauth_provider): """Test token refresh flow with mock provider.""" - proxy = OAuthProxy( + proxy = OAuthDCRProxy( upstream_authorization_endpoint=mock_oauth_provider.authorize_endpoint, upstream_token_endpoint=mock_oauth_provider.token_endpoint, upstream_client_id="mock-client", @@ -818,7 +820,9 @@ class TestOAuthProxyE2E: "scope": "read write", } - with patch("fastmcp.server.auth.oauth_proxy.AsyncOAuth2Client") as MockClient: + with patch( + "fastmcp.server.auth.oauth_dcr_proxy.AsyncOAuth2Client" + ) as MockClient: mock_client = AsyncMock() # Mock initial token exchange to get FastMCP tokens @@ -847,7 +851,7 @@ class TestOAuthProxyE2E: MockClient.return_value = mock_client # Store client code that would be created during OAuth callback - from fastmcp.server.auth.oauth_proxy import ClientCode + from fastmcp.server.auth.oauth_dcr_proxy import ClientCode client_code = ClientCode( code="test-auth-code", @@ -907,7 +911,7 @@ class TestOAuthProxyE2E: """Test PKCE validation with mock provider.""" mock_oauth_provider.require_pkce = True - proxy = OAuthProxy( + proxy = OAuthDCRProxy( upstream_authorization_endpoint=mock_oauth_provider.authorize_endpoint, upstream_token_endpoint=mock_oauth_provider.token_endpoint, upstream_client_id="mock-client", @@ -961,7 +965,7 @@ class TestParameterForwarding: @pytest.fixture def proxy_with_extra_params(self, jwt_verifier): """Create OAuthProxy with extra parameters configured.""" - return OAuthProxy( + return OAuthDCRProxy( upstream_authorization_endpoint="https://oauth.example.com/authorize", upstream_token_endpoint="https://oauth.example.com/token", upstream_client_id="upstream-client", @@ -975,7 +979,7 @@ class TestParameterForwarding: @pytest.fixture def proxy_without_extra_params(self, jwt_verifier): """Create OAuthProxy without extra parameters.""" - return OAuthProxy( + return OAuthDCRProxy( upstream_authorization_endpoint="https://oauth.example.com/authorize", upstream_token_endpoint="https://oauth.example.com/token", upstream_client_id="upstream-client", @@ -1121,7 +1125,7 @@ class TestParameterForwarding: async def test_multiple_extra_params(self, jwt_verifier): """Test multiple extra parameters can be configured and forwarded.""" - proxy = OAuthProxy( + proxy = OAuthDCRProxy( upstream_authorization_endpoint="https://oauth.example.com/authorize", upstream_token_endpoint="https://oauth.example.com/token", upstream_client_id="upstream-client", @@ -1182,7 +1186,7 @@ class TestParameterForwarding: from starlette.applications import Starlette from starlette.testclient import TestClient - proxy = OAuthProxy( + proxy = OAuthDCRProxy( upstream_authorization_endpoint="https://oauth.example.com/authorize", upstream_token_endpoint="https://oauth.example.com/token", upstream_client_id="upstream-client", @@ -1233,7 +1237,7 @@ class TestTokenHandlerErrorTransformation: """Test that client authentication failures return invalid_client with 401.""" from mcp.server.auth.handlers.token import TokenErrorResponse - from fastmcp.server.auth.oauth_proxy import TokenHandler + from fastmcp.server.auth.oauth_dcr_proxy import TokenHandler handler = TokenHandler(provider=Mock(), client_authenticator=Mock()) @@ -1257,7 +1261,7 @@ class TestTokenHandlerErrorTransformation: """Test that grant type authorization errors stay as unauthorized_client with 400.""" from mcp.server.auth.handlers.token import TokenErrorResponse - from fastmcp.server.auth.oauth_proxy import TokenHandler + from fastmcp.server.auth.oauth_dcr_proxy import TokenHandler handler = TokenHandler(provider=Mock(), client_authenticator=Mock()) @@ -1277,7 +1281,7 @@ class TestTokenHandlerErrorTransformation: """Test that other error types pass through unchanged.""" from mcp.server.auth.handlers.token import TokenErrorResponse - from fastmcp.server.auth.oauth_proxy import TokenHandler + from fastmcp.server.auth.oauth_dcr_proxy import TokenHandler handler = TokenHandler(provider=Mock(), client_authenticator=Mock())