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 01/15] 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()) From deb0c3ea95e6af64fe23d9b5d475d0f30d694f82 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 20 Oct 2025 16:01:36 -0400 Subject: [PATCH 02/15] Rename OIDCProxy -> OIDCDCRProxy --- .../fastmcp-server-auth-oidc_proxy.mdx | 2 +- docs/servers/auth/oidc-proxy.mdx | 8 +- src/fastmcp/server/auth/oidc_dcr_proxy.py | 350 +++++ src/fastmcp/server/auth/oidc_proxy.py | 360 +---- src/fastmcp/server/auth/providers/auth0.py | 4 +- src/fastmcp/server/auth/providers/aws.py | 4 +- .../auth/oauth_dcr_proxy/test_oidc_proxy.py | 22 +- tests/server/auth/providers/test_auth0.py | 2 +- tests/server/auth/test_oauth_proxy.py | 1297 ----------------- 9 files changed, 388 insertions(+), 1661 deletions(-) create mode 100644 src/fastmcp/server/auth/oidc_dcr_proxy.py delete mode 100644 tests/server/auth/test_oauth_proxy.py diff --git a/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx index f0124db1e..39360e222 100644 --- a/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx @@ -41,7 +41,7 @@ Get the OIDC configuration for the specified config URL. - `timeout_seconds`: HTTP request timeout in seconds -### `OIDCProxy` +### `OIDCDCRProxy` OAuth provider that wraps OAuthProxy to provide configuration via an OIDC configuration URL. diff --git a/docs/servers/auth/oidc-proxy.mdx b/docs/servers/auth/oidc-proxy.mdx index 22f5e2ee8..bf0b130e9 100644 --- a/docs/servers/auth/oidc-proxy.mdx +++ b/docs/servers/auth/oidc-proxy.mdx @@ -39,10 +39,10 @@ Here's how to implement the OIDC proxy with any provider: ```python from fastmcp import FastMCP -from fastmcp.server.auth.oidc_proxy import OIDCProxy +from fastmcp.server.auth.oidc_dcr_proxy import OIDCDCRProxy # Create the OIDC proxy -auth = OIDCProxy( +auth = OIDCDCRProxy( # Provider's configuration URL config_url="https://provider.com/.well-known/openid-configuration", @@ -62,7 +62,7 @@ mcp = FastMCP(name="My Server", auth=auth) ### Configuration Parameters - + URL of your OAuth provider's OIDC configuration @@ -136,7 +136,7 @@ Set this if your provider requires a specific authentication method and the defa from fastmcp.utilities.storage import InMemoryStorage # Use in-memory storage for testing (clients lost on restart) -auth = OIDCProxy(..., client_storage=InMemoryStorage()) +auth = OIDCDCRProxy(..., client_storage=InMemoryStorage()) ``` diff --git a/src/fastmcp/server/auth/oidc_dcr_proxy.py b/src/fastmcp/server/auth/oidc_dcr_proxy.py new file mode 100644 index 000000000..90e5c4816 --- /dev/null +++ b/src/fastmcp/server/auth/oidc_dcr_proxy.py @@ -0,0 +1,350 @@ +"""OIDC Proxy Provider for FastMCP. + +This provider acts as a transparent proxy to an upstream OIDC compliant Authorization +Server. It leverages the OAuthProxy class to handle Dynamic Client Registration and +forwarding of all OAuth flows. + +This implementation is based on: + OpenID Connect Discovery 1.0 - https://openid.net/specs/openid-connect-discovery-1_0.html + OAuth 2.0 Authorization Server Metadata - https://datatracker.ietf.org/doc/html/rfc8414 +""" + +from collections.abc import Sequence + +import httpx +from key_value.aio.protocols import AsyncKeyValue +from pydantic import AnyHttpUrl, BaseModel, model_validator +from typing_extensions import Self + +from fastmcp.server.auth import TokenVerifier +from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy +from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +class OIDCConfiguration(BaseModel): + """OIDC Configuration. + + See: + https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + https://datatracker.ietf.org/doc/html/rfc8414#section-2 + """ + + strict: bool = True + + # OpenID Connect Discovery 1.0 + issuer: AnyHttpUrl | str | None = None # Strict + + authorization_endpoint: AnyHttpUrl | str | None = None # Strict + token_endpoint: AnyHttpUrl | str | None = None # Strict + userinfo_endpoint: AnyHttpUrl | str | None = None + + jwks_uri: AnyHttpUrl | str | None = None # Strict + + registration_endpoint: AnyHttpUrl | str | None = None + + scopes_supported: Sequence[str] | None = None + + response_types_supported: Sequence[str] | None = None # Strict + response_modes_supported: Sequence[str] | None = None + + grant_types_supported: Sequence[str] | None = None + + acr_values_supported: Sequence[str] | None = None + + subject_types_supported: Sequence[str] | None = None # Strict + + id_token_signing_alg_values_supported: Sequence[str] | None = None # Strict + id_token_encryption_alg_values_supported: Sequence[str] | None = None + id_token_encryption_enc_values_supported: Sequence[str] | None = None + + userinfo_signing_alg_values_supported: Sequence[str] | None = None + userinfo_encryption_alg_values_supported: Sequence[str] | None = None + userinfo_encryption_enc_values_supported: Sequence[str] | None = None + + request_object_signing_alg_values_supported: Sequence[str] | None = None + request_object_encryption_alg_values_supported: Sequence[str] | None = None + request_object_encryption_enc_values_supported: Sequence[str] | None = None + + token_endpoint_auth_methods_supported: Sequence[str] | None = None + token_endpoint_auth_signing_alg_values_supported: Sequence[str] | None = None + + display_values_supported: Sequence[str] | None = None + + claim_types_supported: Sequence[str] | None = None + claims_supported: Sequence[str] | None = None + + service_documentation: AnyHttpUrl | str | None = None + + claims_locales_supported: Sequence[str] | None = None + ui_locales_supported: Sequence[str] | None = None + + claims_parameter_supported: bool | None = None + request_parameter_supported: bool | None = None + request_uri_parameter_supported: bool | None = None + + require_request_uri_registration: bool | None = None + + op_policy_uri: AnyHttpUrl | str | None = None + op_tos_uri: AnyHttpUrl | str | None = None + + # OAuth 2.0 Authorization Server Metadata + revocation_endpoint: AnyHttpUrl | str | None = None + revocation_endpoint_auth_methods_supported: Sequence[str] | None = None + revocation_endpoint_auth_signing_alg_values_supported: Sequence[str] | None = None + + introspection_endpoint: AnyHttpUrl | str | None = None + introspection_endpoint_auth_methods_supported: Sequence[str] | None = None + introspection_endpoint_auth_signing_alg_values_supported: Sequence[str] | None = ( + None + ) + + code_challenge_methods_supported: Sequence[str] | None = None + + signed_metadata: str | None = None + + @model_validator(mode="after") + def _enforce_strict(self) -> Self: + """Enforce strict rules.""" + if not self.strict: + return self + + def enforce(attr: str, is_url: bool = False) -> None: + value = getattr(self, attr, None) + if not value: + message = f"Missing required configuration metadata: {attr}" + logger.error(message) + raise ValueError(message) + + if not is_url or isinstance(value, AnyHttpUrl): + return + + try: + AnyHttpUrl(value) + except Exception: + message = f"Invalid URL for configuration metadata: {attr}" + logger.error(message) + raise ValueError(message) + + enforce("issuer", True) + enforce("authorization_endpoint", True) + enforce("token_endpoint", True) + enforce("jwks_uri", True) + enforce("response_types_supported") + enforce("subject_types_supported") + enforce("id_token_signing_alg_values_supported") + + return self + + @classmethod + def get_oidc_configuration( + cls, config_url: AnyHttpUrl, *, strict: bool | None, timeout_seconds: int | None + ) -> Self: + """Get the OIDC configuration for the specified config URL. + + Args: + config_url: The OIDC config URL + strict: The strict flag for the configuration + timeout_seconds: HTTP request timeout in seconds + """ + get_kwargs = {} + if timeout_seconds is not None: + get_kwargs["timeout"] = timeout_seconds + + try: + response = httpx.get(str(config_url), **get_kwargs) + response.raise_for_status() + + config_data = response.json() + if strict is not None: + config_data["strict"] = strict + + return cls.model_validate(config_data) + except Exception: + logger.exception( + f"Unable to get OIDC configuration for config url: {config_url}" + ) + raise + + +class OIDCDCRProxy(OAuthDCRProxy): + """OAuth provider that wraps OAuthDCRProxy to provide configuration via an OIDC configuration URL. + + This provider makes it easier to add OAuth protection for any upstream provider + that is OIDC compliant. + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.auth.oidc_dcr_proxy import OIDCDCRProxy + + # Simple OIDC based protection + auth = OIDCDCRProxy( + config_url="https://oidc.config.url", + client_id="your-oidc-client-id", + client_secret="your-oidc-client-secret", + base_url="https://your.server.url", + ) + + mcp = FastMCP("My Protected Server", auth=auth) + ``` + """ + + oidc_config: OIDCConfiguration + + def __init__( + self, + *, + # OIDC configuration + config_url: AnyHttpUrl | str, + strict: bool | None = None, + # Upstream server configuration + client_id: str, + client_secret: str, + audience: str | None = None, + timeout_seconds: int | None = None, + # Token verifier + algorithm: str | None = None, + required_scopes: list[str] | None = None, + # FastMCP server configuration + base_url: AnyHttpUrl | str, + issuer_url: AnyHttpUrl | str | None = None, + redirect_path: str | None = None, + # Client configuration + allowed_client_redirect_uris: list[str] | None = None, + client_storage: AsyncKeyValue | None = None, + # Token validation configuration + token_endpoint_auth_method: str | None = None, + ) -> None: + """Initialize the OIDC proxy provider. + + Args: + config_url: URL of upstream configuration + strict: Optional strict flag for the configuration + client_id: Client ID registered with upstream server + client_secret: Client secret for upstream server + audience: Audience for upstream server + timeout_seconds: HTTP request timeout in seconds + algorithm: Token verifier algorithm + required_scopes: Required OAuth scopes + base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) + issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL + to avoid 404s during discovery when mounting under a path. + redirect_path: Redirect path configured in upstream OAuth app (defaults to "/auth/callback") + 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. + client_storage: An AsyncKeyValue-compatible store for client registrations, registrations are stored in memory if not provided + 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"). + """ + if not config_url: + raise ValueError("Missing required config URL") + + if not client_id: + raise ValueError("Missing required client id") + + if not client_secret: + raise ValueError("Missing required client secret") + + if not base_url: + raise ValueError("Missing required base URL") + + if isinstance(config_url, str): + config_url = AnyHttpUrl(config_url) + + self.oidc_config = self.get_oidc_configuration( + config_url, strict, timeout_seconds + ) + if ( + not self.oidc_config.authorization_endpoint + or not self.oidc_config.token_endpoint + ): + logger.debug(f"Invalid OIDC Configuration: {self.oidc_config}") + raise ValueError("Missing required OIDC endpoints") + + revocation_endpoint = ( + str(self.oidc_config.revocation_endpoint) + if self.oidc_config.revocation_endpoint + else None + ) + + token_verifier = self.get_token_verifier( + algorithm=algorithm, + audience=audience, + required_scopes=required_scopes, + timeout_seconds=timeout_seconds, + ) + + init_kwargs = { + "upstream_authorization_endpoint": str( + self.oidc_config.authorization_endpoint + ), + "upstream_token_endpoint": str(self.oidc_config.token_endpoint), + "upstream_client_id": client_id, + "upstream_client_secret": client_secret, + "upstream_revocation_endpoint": revocation_endpoint, + "token_verifier": token_verifier, + "base_url": base_url, + "issuer_url": issuer_url or base_url, + "service_documentation_url": self.oidc_config.service_documentation, + "allowed_client_redirect_uris": allowed_client_redirect_uris, + "client_storage": client_storage, + "token_endpoint_auth_method": token_endpoint_auth_method, + } + + if redirect_path: + init_kwargs["redirect_path"] = redirect_path + + if audience: + extra_params = {"audience": audience} + init_kwargs["extra_authorize_params"] = extra_params + init_kwargs["extra_token_params"] = extra_params + + super().__init__(**init_kwargs) + + def get_oidc_configuration( + self, + config_url: AnyHttpUrl, + strict: bool | None, + timeout_seconds: int | None, + ) -> OIDCConfiguration: + """Gets the OIDC configuration for the specified configuration URL. + + Args: + config_url: The OIDC configuration URL + strict: The strict flag for the configuration + timeout_seconds: HTTP request timeout in seconds + """ + return OIDCConfiguration.get_oidc_configuration( + config_url, strict=strict, timeout_seconds=timeout_seconds + ) + + def get_token_verifier( + self, + *, + algorithm: str | None = None, + audience: str | None = None, + required_scopes: list[str] | None = None, + timeout_seconds: int | None = None, + ) -> TokenVerifier: + """Creates the token verifier for the specified OIDC configuration and arguments. + + Args: + algorithm: Optional token verifier algorithm + audience: Optional token verifier audience + required_scopes: Optional token verifier required_scopes + timeout_seconds: HTTP request timeout in seconds + """ + return JWTVerifier( + jwks_uri=str(self.oidc_config.jwks_uri), + issuer=str(self.oidc_config.issuer), + algorithm=algorithm, + audience=audience, + required_scopes=required_scopes, + ) diff --git a/src/fastmcp/server/auth/oidc_proxy.py b/src/fastmcp/server/auth/oidc_proxy.py index 7084d1b02..529a63987 100644 --- a/src/fastmcp/server/auth/oidc_proxy.py +++ b/src/fastmcp/server/auth/oidc_proxy.py @@ -1,350 +1,24 @@ -"""OIDC Proxy Provider for FastMCP. +"""Backwards compatibility shim for oidc_proxy.py -This provider acts as a transparent proxy to an upstream OIDC compliant Authorization -Server. It leverages the OAuthProxy class to handle Dynamic Client Registration and -forwarding of all OAuth flows. - -This implementation is based on: - OpenID Connect Discovery 1.0 - https://openid.net/specs/openid-connect-discovery-1_0.html - OAuth 2.0 Authorization Server Metadata - https://datatracker.ietf.org/doc/html/rfc8414 +The OIDCProxy class has been moved to fastmcp.server.auth.oidc_dcr_proxy.OIDCDCRProxy +for better organization. This module provides a backwards-compatible import. """ -from collections.abc import Sequence +import warnings -import httpx -from key_value.aio.protocols import AsyncKeyValue -from pydantic import AnyHttpUrl, BaseModel, model_validator -from typing_extensions import Self +import fastmcp +from fastmcp.server.auth.oidc_dcr_proxy import OIDCDCRProxy as OIDCProxy -from fastmcp.server.auth import TokenVerifier -from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy -from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.utilities.logging import get_logger +# Re-export for backwards compatibility +__all__ = ["OIDCProxy"] -logger = get_logger(__name__) - - -class OIDCConfiguration(BaseModel): - """OIDC Configuration. - - See: - https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata - https://datatracker.ietf.org/doc/html/rfc8414#section-2 - """ - - strict: bool = True - - # OpenID Connect Discovery 1.0 - issuer: AnyHttpUrl | str | None = None # Strict - - authorization_endpoint: AnyHttpUrl | str | None = None # Strict - token_endpoint: AnyHttpUrl | str | None = None # Strict - userinfo_endpoint: AnyHttpUrl | str | None = None - - jwks_uri: AnyHttpUrl | str | None = None # Strict - - registration_endpoint: AnyHttpUrl | str | None = None - - scopes_supported: Sequence[str] | None = None - - response_types_supported: Sequence[str] | None = None # Strict - response_modes_supported: Sequence[str] | None = None - - grant_types_supported: Sequence[str] | None = None - - acr_values_supported: Sequence[str] | None = None - - subject_types_supported: Sequence[str] | None = None # Strict - - id_token_signing_alg_values_supported: Sequence[str] | None = None # Strict - id_token_encryption_alg_values_supported: Sequence[str] | None = None - id_token_encryption_enc_values_supported: Sequence[str] | None = None - - userinfo_signing_alg_values_supported: Sequence[str] | None = None - userinfo_encryption_alg_values_supported: Sequence[str] | None = None - userinfo_encryption_enc_values_supported: Sequence[str] | None = None - - request_object_signing_alg_values_supported: Sequence[str] | None = None - request_object_encryption_alg_values_supported: Sequence[str] | None = None - request_object_encryption_enc_values_supported: Sequence[str] | None = None - - token_endpoint_auth_methods_supported: Sequence[str] | None = None - token_endpoint_auth_signing_alg_values_supported: Sequence[str] | None = None - - display_values_supported: Sequence[str] | None = None - - claim_types_supported: Sequence[str] | None = None - claims_supported: Sequence[str] | None = None - - service_documentation: AnyHttpUrl | str | None = None - - claims_locales_supported: Sequence[str] | None = None - ui_locales_supported: Sequence[str] | None = None - - claims_parameter_supported: bool | None = None - request_parameter_supported: bool | None = None - request_uri_parameter_supported: bool | None = None - - require_request_uri_registration: bool | None = None - - op_policy_uri: AnyHttpUrl | str | None = None - op_tos_uri: AnyHttpUrl | str | None = None - - # OAuth 2.0 Authorization Server Metadata - revocation_endpoint: AnyHttpUrl | str | None = None - revocation_endpoint_auth_methods_supported: Sequence[str] | None = None - revocation_endpoint_auth_signing_alg_values_supported: Sequence[str] | None = None - - introspection_endpoint: AnyHttpUrl | str | None = None - introspection_endpoint_auth_methods_supported: Sequence[str] | None = None - introspection_endpoint_auth_signing_alg_values_supported: Sequence[str] | None = ( - None +# Deprecated in 2.13 +if fastmcp.settings.deprecation_warnings: + warnings.warn( + "The `fastmcp.server.auth.oidc_proxy` module is deprecated " + "and will be removed in a future version. " + "Please use `fastmcp.server.auth.oidc_dcr_proxy.OIDCDCRProxy` " + "instead of this module's OIDCProxy.", + DeprecationWarning, + stacklevel=2, ) - - code_challenge_methods_supported: Sequence[str] | None = None - - signed_metadata: str | None = None - - @model_validator(mode="after") - def _enforce_strict(self) -> Self: - """Enforce strict rules.""" - if not self.strict: - return self - - def enforce(attr: str, is_url: bool = False) -> None: - value = getattr(self, attr, None) - if not value: - message = f"Missing required configuration metadata: {attr}" - logger.error(message) - raise ValueError(message) - - if not is_url or isinstance(value, AnyHttpUrl): - return - - try: - AnyHttpUrl(value) - except Exception: - message = f"Invalid URL for configuration metadata: {attr}" - logger.error(message) - raise ValueError(message) - - enforce("issuer", True) - enforce("authorization_endpoint", True) - enforce("token_endpoint", True) - enforce("jwks_uri", True) - enforce("response_types_supported") - enforce("subject_types_supported") - enforce("id_token_signing_alg_values_supported") - - return self - - @classmethod - def get_oidc_configuration( - cls, config_url: AnyHttpUrl, *, strict: bool | None, timeout_seconds: int | None - ) -> Self: - """Get the OIDC configuration for the specified config URL. - - Args: - config_url: The OIDC config URL - strict: The strict flag for the configuration - timeout_seconds: HTTP request timeout in seconds - """ - get_kwargs = {} - if timeout_seconds is not None: - get_kwargs["timeout"] = timeout_seconds - - try: - response = httpx.get(str(config_url), **get_kwargs) - response.raise_for_status() - - config_data = response.json() - if strict is not None: - config_data["strict"] = strict - - return cls.model_validate(config_data) - except Exception: - logger.exception( - f"Unable to get OIDC configuration for config url: {config_url}" - ) - raise - - -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 - that is OIDC compliant. - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.oidc_proxy import OIDCProxy - - # Simple OIDC based protection - auth = OIDCProxy( - config_url="https://oidc.config.url", - client_id="your-oidc-client-id", - client_secret="your-oidc-client-secret", - base_url="https://your.server.url", - ) - - mcp = FastMCP("My Protected Server", auth=auth) - ``` - """ - - oidc_config: OIDCConfiguration - - def __init__( - self, - *, - # OIDC configuration - config_url: AnyHttpUrl | str, - strict: bool | None = None, - # Upstream server configuration - client_id: str, - client_secret: str, - audience: str | None = None, - timeout_seconds: int | None = None, - # Token verifier - algorithm: str | None = None, - required_scopes: list[str] | None = None, - # FastMCP server configuration - base_url: AnyHttpUrl | str, - issuer_url: AnyHttpUrl | str | None = None, - redirect_path: str | None = None, - # Client configuration - allowed_client_redirect_uris: list[str] | None = None, - client_storage: AsyncKeyValue | None = None, - # Token validation configuration - token_endpoint_auth_method: str | None = None, - ) -> None: - """Initialize the OIDC proxy provider. - - Args: - config_url: URL of upstream configuration - strict: Optional strict flag for the configuration - client_id: Client ID registered with upstream server - client_secret: Client secret for upstream server - audience: Audience for upstream server - timeout_seconds: HTTP request timeout in seconds - algorithm: Token verifier algorithm - required_scopes: Required OAuth scopes - base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) - issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL - to avoid 404s during discovery when mounting under a path. - redirect_path: Redirect path configured in upstream OAuth app (defaults to "/auth/callback") - 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. - client_storage: An AsyncKeyValue-compatible store for client registrations, registrations are stored in memory if not provided - 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"). - """ - if not config_url: - raise ValueError("Missing required config URL") - - if not client_id: - raise ValueError("Missing required client id") - - if not client_secret: - raise ValueError("Missing required client secret") - - if not base_url: - raise ValueError("Missing required base URL") - - if isinstance(config_url, str): - config_url = AnyHttpUrl(config_url) - - self.oidc_config = self.get_oidc_configuration( - config_url, strict, timeout_seconds - ) - if ( - not self.oidc_config.authorization_endpoint - or not self.oidc_config.token_endpoint - ): - logger.debug(f"Invalid OIDC Configuration: {self.oidc_config}") - raise ValueError("Missing required OIDC endpoints") - - revocation_endpoint = ( - str(self.oidc_config.revocation_endpoint) - if self.oidc_config.revocation_endpoint - else None - ) - - token_verifier = self.get_token_verifier( - algorithm=algorithm, - audience=audience, - required_scopes=required_scopes, - timeout_seconds=timeout_seconds, - ) - - init_kwargs = { - "upstream_authorization_endpoint": str( - self.oidc_config.authorization_endpoint - ), - "upstream_token_endpoint": str(self.oidc_config.token_endpoint), - "upstream_client_id": client_id, - "upstream_client_secret": client_secret, - "upstream_revocation_endpoint": revocation_endpoint, - "token_verifier": token_verifier, - "base_url": base_url, - "issuer_url": issuer_url or base_url, - "service_documentation_url": self.oidc_config.service_documentation, - "allowed_client_redirect_uris": allowed_client_redirect_uris, - "client_storage": client_storage, - "token_endpoint_auth_method": token_endpoint_auth_method, - } - - if redirect_path: - init_kwargs["redirect_path"] = redirect_path - - if audience: - extra_params = {"audience": audience} - init_kwargs["extra_authorize_params"] = extra_params - init_kwargs["extra_token_params"] = extra_params - - super().__init__(**init_kwargs) - - def get_oidc_configuration( - self, - config_url: AnyHttpUrl, - strict: bool | None, - timeout_seconds: int | None, - ) -> OIDCConfiguration: - """Gets the OIDC configuration for the specified configuration URL. - - Args: - config_url: The OIDC configuration URL - strict: The strict flag for the configuration - timeout_seconds: HTTP request timeout in seconds - """ - return OIDCConfiguration.get_oidc_configuration( - config_url, strict=strict, timeout_seconds=timeout_seconds - ) - - def get_token_verifier( - self, - *, - algorithm: str | None = None, - audience: str | None = None, - required_scopes: list[str] | None = None, - timeout_seconds: int | None = None, - ) -> TokenVerifier: - """Creates the token verifier for the specified OIDC configuration and arguments. - - Args: - algorithm: Optional token verifier algorithm - audience: Optional token verifier audience - required_scopes: Optional token verifier required_scopes - timeout_seconds: HTTP request timeout in seconds - """ - return JWTVerifier( - jwks_uri=str(self.oidc_config.jwks_uri), - issuer=str(self.oidc_config.issuer), - algorithm=algorithm, - audience=audience, - required_scopes=required_scopes, - ) diff --git a/src/fastmcp/server/auth/providers/auth0.py b/src/fastmcp/server/auth/providers/auth0.py index 4d994ce98..71b4e63ac 100644 --- a/src/fastmcp/server/auth/providers/auth0.py +++ b/src/fastmcp/server/auth/providers/auth0.py @@ -25,7 +25,7 @@ from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict -from fastmcp.server.auth.oidc_proxy import OIDCProxy +from fastmcp.server.auth.oidc_dcr_proxy import OIDCDCRProxy from fastmcp.settings import ENV_FILE from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger @@ -59,7 +59,7 @@ class Auth0ProviderSettings(BaseSettings): return parse_scopes(v) -class Auth0Provider(OIDCProxy): +class Auth0Provider(OIDCDCRProxy): """An Auth0 provider implementation for FastMCP. This provider is a complete Auth0 integration that's ready to use with diff --git a/src/fastmcp/server/auth/providers/aws.py b/src/fastmcp/server/auth/providers/aws.py index 31de6c9a0..84fe55eef 100644 --- a/src/fastmcp/server/auth/providers/aws.py +++ b/src/fastmcp/server/auth/providers/aws.py @@ -29,7 +29,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken -from fastmcp.server.auth.oidc_proxy import OIDCProxy +from fastmcp.server.auth.oidc_dcr_proxy import OIDCDCRProxy from fastmcp.server.auth.providers.jwt import JWTVerifier from fastmcp.settings import ENV_FILE from fastmcp.utilities.auth import parse_scopes @@ -91,7 +91,7 @@ class AWSCognitoTokenVerifier(JWTVerifier): ) -class AWSCognitoProvider(OIDCProxy): +class AWSCognitoProvider(OIDCDCRProxy): """Complete AWS Cognito OAuth provider for FastMCP. This provider makes it trivial to add AWS Cognito OAuth protection to any diff --git a/tests/server/auth/oauth_dcr_proxy/test_oidc_proxy.py b/tests/server/auth/oauth_dcr_proxy/test_oidc_proxy.py index f45f835be..86384af3d 100644 --- a/tests/server/auth/oauth_dcr_proxy/test_oidc_proxy.py +++ b/tests/server/auth/oauth_dcr_proxy/test_oidc_proxy.py @@ -7,7 +7,7 @@ import pytest from httpx import Response from pydantic import AnyHttpUrl -from fastmcp.server.auth.oidc_proxy import OIDCConfiguration, OIDCProxy +from fastmcp.server.auth.oidc_dcr_proxy import OIDCConfiguration, OIDCDCRProxy from fastmcp.server.auth.providers.jwt import JWTVerifier TEST_ISSUER = "https://example.com" @@ -440,7 +440,7 @@ def validate_proxy(mock_get, proxy, oidc_config): assert proxy.oidc_config == oidc_config -class TestOIDCProxyInitialization: +class TestOIDCDCRProxyInitialization: """Tests for OIDC proxy initialization.""" def test_default_initialization(self, valid_oidc_configuration_dict): @@ -453,7 +453,7 @@ class TestOIDCProxyInitialization: ) mock_get.return_value = oidc_config - proxy = OIDCProxy( + proxy = OIDCDCRProxy( config_url=TEST_CONFIG_URL, client_id=TEST_CLIENT_ID, client_secret=TEST_CLIENT_SECRET, @@ -472,7 +472,7 @@ class TestOIDCProxyInitialization: ) mock_get.return_value = oidc_config - proxy = OIDCProxy( + proxy = OIDCDCRProxy( config_url=TEST_CONFIG_URL, client_id=TEST_CLIENT_ID, client_secret=TEST_CLIENT_SECRET, @@ -495,7 +495,7 @@ class TestOIDCProxyInitialization: ) mock_get.return_value = oidc_config - proxy = OIDCProxy( + proxy = OIDCDCRProxy( config_url=TEST_CONFIG_URL, client_id=TEST_CLIENT_ID, client_secret=TEST_CLIENT_SECRET, @@ -523,7 +523,7 @@ class TestOIDCProxyInitialization: ) mock_get.return_value = oidc_config - proxy = OIDCProxy( + proxy = OIDCDCRProxy( config_url=TEST_CONFIG_URL, client_id=TEST_CLIENT_ID, client_secret=TEST_CLIENT_SECRET, @@ -548,7 +548,7 @@ class TestOIDCProxyInitialization: ) mock_get.return_value = oidc_config - proxy = OIDCProxy( + proxy = OIDCDCRProxy( config_url=TEST_CONFIG_URL, client_id=TEST_CLIENT_ID, client_secret=TEST_CLIENT_SECRET, @@ -577,7 +577,7 @@ class TestOIDCProxyInitialization: mock_get.return_value = oidc_config with pytest.raises(ValueError, match="Missing required config URL"): - OIDCProxy( + OIDCDCRProxy( config_url=None, # type: ignore client_id=TEST_CLIENT_ID, client_secret=TEST_CLIENT_SECRET, @@ -597,7 +597,7 @@ class TestOIDCProxyInitialization: mock_get.return_value = oidc_config with pytest.raises(ValueError, match="Missing required client id"): - OIDCProxy( + OIDCDCRProxy( config_url=TEST_CONFIG_URL, client_id=None, # type: ignore client_secret=TEST_CLIENT_SECRET, @@ -617,7 +617,7 @@ class TestOIDCProxyInitialization: mock_get.return_value = oidc_config with pytest.raises(ValueError, match="Missing required client secret"): - OIDCProxy( + OIDCDCRProxy( config_url=TEST_CONFIG_URL, client_id=TEST_CLIENT_ID, client_secret=None, # type: ignore @@ -637,7 +637,7 @@ class TestOIDCProxyInitialization: mock_get.return_value = oidc_config with pytest.raises(ValueError, match="Missing required base URL"): - OIDCProxy( + OIDCDCRProxy( config_url=TEST_CONFIG_URL, client_id=TEST_CLIENT_ID, client_secret=TEST_CLIENT_SECRET, diff --git a/tests/server/auth/providers/test_auth0.py b/tests/server/auth/providers/test_auth0.py index 01f60e54b..87b6b2424 100644 --- a/tests/server/auth/providers/test_auth0.py +++ b/tests/server/auth/providers/test_auth0.py @@ -5,7 +5,7 @@ from unittest.mock import patch import pytest -from fastmcp.server.auth.oidc_proxy import OIDCConfiguration +from fastmcp.server.auth.oidc_dcr_proxy import OIDCConfiguration from fastmcp.server.auth.providers.auth0 import Auth0Provider, Auth0ProviderSettings from fastmcp.server.auth.providers.jwt import JWTVerifier diff --git a/tests/server/auth/test_oauth_proxy.py b/tests/server/auth/test_oauth_proxy.py deleted file mode 100644 index 89b4ac63b..000000000 --- a/tests/server/auth/test_oauth_proxy.py +++ /dev/null @@ -1,1297 +0,0 @@ -"""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 From 0ad638003ca8bf233b697f05c154e4d634bb65e9 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 20 Oct 2025 17:32:35 -0400 Subject: [PATCH 03/15] Rename OAuth providers to include DCR suffix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames all OAuth providers that inherit from OAuthDCRProxy to explicitly include "DCR" in their names, clarifying their Dynamic Client Registration implementation approach. Changes: - GitHubProvider → GitHubDCRProvider - GoogleProvider → GoogleDCRProvider - AzureProvider → AzureDCRProvider - WorkOSProvider → WorkOSDCRProvider - Auth0Provider → Auth0DCRProvider - AWSCognitoProvider → AWSCognitoDCRProvider All old names remain as deprecated aliases with warnings that respect settings.deprecation_warnings. Environment variables updated to include _DCR_ with backwards compatibility via env_prefixes. --- src/fastmcp/server/auth/providers/auth0.py | 109 ++++++++++++------ src/fastmcp/server/auth/providers/aws.py | 106 +++++++++++++----- src/fastmcp/server/auth/providers/azure.py | 110 ++++++++++++------ src/fastmcp/server/auth/providers/github.py | 102 ++++++++++++----- src/fastmcp/server/auth/providers/google.py | 100 ++++++++++++----- src/fastmcp/server/auth/providers/workos.py | 111 +++++++++++++------ tests/deprecated/test_oauth_dcr_providers.py | 63 +++++++++++ tests/server/auth/providers/test_auth0.py | 81 +++++++------- tests/server/auth/providers/test_aws.py | 66 +++++------ tests/server/auth/providers/test_azure.py | 46 ++++---- tests/server/auth/providers/test_github.py | 56 +++++----- tests/server/auth/providers/test_google.py | 30 ++--- tests/server/auth/providers/test_workos.py | 38 +++---- 13 files changed, 676 insertions(+), 342 deletions(-) create mode 100644 tests/deprecated/test_oauth_dcr_providers.py diff --git a/src/fastmcp/server/auth/providers/auth0.py b/src/fastmcp/server/auth/providers/auth0.py index 71b4e63ac..3ba7cdc95 100644 --- a/src/fastmcp/server/auth/providers/auth0.py +++ b/src/fastmcp/server/auth/providers/auth0.py @@ -6,10 +6,10 @@ just the configuration URL, client ID, client secret, audience, and base URL. Example: ```python from fastmcp import FastMCP - from fastmcp.server.auth.providers.auth0 import Auth0Provider + from fastmcp.server.auth.providers.auth0 import Auth0DCRProvider # Simple Auth0 OAuth protection - auth = Auth0Provider( + auth = Auth0DCRProvider( config_url="https://auth0.config.url", client_id="your-auth0-client-id", client_secret="your-auth0-client-secret", @@ -21,12 +21,19 @@ Example: ``` """ +import warnings + from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import BaseSettings from fastmcp.server.auth.oidc_dcr_proxy import OIDCDCRProxy -from fastmcp.settings import ENV_FILE +from fastmcp.settings import ( + ENV_FILE, + ExtendedEnvSettingsSource, + ExtendedSettingsConfigDict, + settings, +) from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -34,15 +41,32 @@ from fastmcp.utilities.types import NotSet, NotSetT logger = get_logger(__name__) -class Auth0ProviderSettings(BaseSettings): - """Settings for Auth0 OIDC provider.""" +class Auth0DCRProviderSettings(BaseSettings): + """Settings for Auth0 OIDC DCR provider.""" - model_config = SettingsConfigDict( - env_prefix="FASTMCP_SERVER_AUTH_AUTH0_", + model_config = ExtendedSettingsConfigDict( + env_prefix="FASTMCP_SERVER_AUTH_AUTH0_DCR_", + env_prefixes=["FASTMCP_SERVER_AUTH_AUTH0_DCR_", "FASTMCP_SERVER_AUTH_AUTH0_"], env_file=ENV_FILE, extra="ignore", ) + @classmethod + def settings_customise_sources( + cls, + settings_cls, + init_settings, + env_settings, + dotenv_settings, + file_secret_settings, + ): + return ( + init_settings, + ExtendedEnvSettingsSource(settings_cls), + dotenv_settings, + file_secret_settings, + ) + config_url: AnyHttpUrl | None = None client_id: str | None = None client_secret: SecretStr | None = None @@ -59,8 +83,8 @@ class Auth0ProviderSettings(BaseSettings): return parse_scopes(v) -class Auth0Provider(OIDCDCRProxy): - """An Auth0 provider implementation for FastMCP. +class Auth0DCRProvider(OIDCDCRProxy): + """An Auth0 DCR provider implementation for FastMCP. This provider is a complete Auth0 integration that's ready to use with just the configuration URL, client ID, client secret, audience, and base URL. @@ -68,10 +92,10 @@ class Auth0Provider(OIDCDCRProxy): Example: ```python from fastmcp import FastMCP - from fastmcp.server.auth.providers.auth0 import Auth0Provider + from fastmcp.server.auth.providers.auth0 import Auth0DCRProvider # Simple Auth0 OAuth protection - auth = Auth0Provider( + auth = Auth0DCRProvider( config_url="https://auth0.config.url", client_id="your-auth0-client-id", client_secret="your-auth0-client-secret", @@ -113,7 +137,7 @@ class Auth0Provider(OIDCDCRProxy): If None (default), all URIs are allowed. If empty list, no URIs are allowed. client_storage: An AsyncKeyValue-compatible store for client registrations, registrations are stored in memory if not provided """ - settings = Auth0ProviderSettings.model_validate( + provider_settings = Auth0DCRProviderSettings.model_validate( { k: v for k, v in { @@ -131,50 +155,67 @@ class Auth0Provider(OIDCDCRProxy): } ) - if not settings.config_url: + if not provider_settings.config_url: raise ValueError( - "config_url is required - set via parameter or FASTMCP_SERVER_AUTH_AUTH0_CONFIG_URL" + "config_url is required - set via parameter or FASTMCP_SERVER_AUTH_AUTH0_DCR_CONFIG_URL" ) - if not settings.client_id: + if not provider_settings.client_id: raise ValueError( - "client_id is required - set via parameter or FASTMCP_SERVER_AUTH_AUTH0_CLIENT_ID" + "client_id is required - set via parameter or FASTMCP_SERVER_AUTH_AUTH0_DCR_CLIENT_ID" ) - if not settings.client_secret: + if not provider_settings.client_secret: raise ValueError( - "client_secret is required - set via parameter or FASTMCP_SERVER_AUTH_AUTH0_CLIENT_SECRET" + "client_secret is required - set via parameter or FASTMCP_SERVER_AUTH_AUTH0_DCR_CLIENT_SECRET" ) - if not settings.audience: + if not provider_settings.audience: raise ValueError( - "audience is required - set via parameter or FASTMCP_SERVER_AUTH_AUTH0_AUDIENCE" + "audience is required - set via parameter or FASTMCP_SERVER_AUTH_AUTH0_DCR_AUDIENCE" ) - if not settings.base_url: + if not provider_settings.base_url: raise ValueError( - "base_url is required - set via parameter or FASTMCP_SERVER_AUTH_AUTH0_BASE_URL" + "base_url is required - set via parameter or FASTMCP_SERVER_AUTH_AUTH0_DCR_BASE_URL" ) - auth0_required_scopes = settings.required_scopes or ["openid"] + auth0_required_scopes = provider_settings.required_scopes or ["openid"] init_kwargs = { - "config_url": settings.config_url, - "client_id": settings.client_id, - "client_secret": settings.client_secret.get_secret_value(), - "audience": settings.audience, - "base_url": settings.base_url, - "issuer_url": settings.issuer_url, - "redirect_path": settings.redirect_path, + "config_url": provider_settings.config_url, + "client_id": provider_settings.client_id, + "client_secret": provider_settings.client_secret.get_secret_value(), + "audience": provider_settings.audience, + "base_url": provider_settings.base_url, + "issuer_url": provider_settings.issuer_url, + "redirect_path": provider_settings.redirect_path, "required_scopes": auth0_required_scopes, - "allowed_client_redirect_uris": settings.allowed_client_redirect_uris, + "allowed_client_redirect_uris": provider_settings.allowed_client_redirect_uris, "client_storage": client_storage, } super().__init__(**init_kwargs) logger.info( - "Initialized Auth0 OAuth provider for client %s with scopes: %s", - settings.client_id, + "Initialized Auth0 OAuth DCR provider for client %s with scopes: %s", + provider_settings.client_id, auth0_required_scopes, ) + + +# Deprecated alias for backwards compatibility +class Auth0Provider(Auth0DCRProvider): + """Deprecated: Use Auth0DCRProvider instead. + + This alias is provided for backwards compatibility and will be removed in a future version. + """ + + def __init__(self, **kwargs): + if settings.deprecation_warnings: + warnings.warn( + "Auth0Provider is deprecated, use Auth0DCRProvider instead", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(**kwargs) diff --git a/src/fastmcp/server/auth/providers/aws.py b/src/fastmcp/server/auth/providers/aws.py index 84fe55eef..219facc95 100644 --- a/src/fastmcp/server/auth/providers/aws.py +++ b/src/fastmcp/server/auth/providers/aws.py @@ -23,15 +23,22 @@ Example: from __future__ import annotations +import warnings + from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import BaseSettings from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.oidc_dcr_proxy import OIDCDCRProxy from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.settings import ENV_FILE +from fastmcp.settings import ( + ENV_FILE, + ExtendedEnvSettingsSource, + ExtendedSettingsConfigDict, + settings, +) from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -39,15 +46,35 @@ from fastmcp.utilities.types import NotSet, NotSetT logger = get_logger(__name__) -class AWSCognitoProviderSettings(BaseSettings): - """Settings for AWS Cognito OAuth provider.""" +class AWSCognitoDCRProviderSettings(BaseSettings): + """Settings for AWS Cognito OAuth DCR provider.""" - model_config = SettingsConfigDict( - env_prefix="FASTMCP_SERVER_AUTH_AWS_COGNITO_", + model_config = ExtendedSettingsConfigDict( + env_prefix="FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_", + env_prefixes=[ + "FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_", + ], env_file=ENV_FILE, extra="ignore", ) + @classmethod + def settings_customise_sources( + cls, + settings_cls, + init_settings, + env_settings, + dotenv_settings, + file_secret_settings, + ): + return ( + init_settings, + ExtendedEnvSettingsSource(settings_cls), + dotenv_settings, + file_secret_settings, + ) + user_pool_id: str | None = None aws_region: str | None = None client_id: str | None = None @@ -91,8 +118,8 @@ class AWSCognitoTokenVerifier(JWTVerifier): ) -class AWSCognitoProvider(OIDCDCRProxy): - """Complete AWS Cognito OAuth provider for FastMCP. +class AWSCognitoDCRProvider(OIDCDCRProxy): + """Complete AWS Cognito OAuth DCR provider for FastMCP. This provider makes it trivial to add AWS Cognito OAuth protection to any FastMCP server using OIDC Discovery. Just provide your Cognito User Pool details, @@ -107,9 +134,9 @@ class AWSCognitoProvider(OIDCDCRProxy): Example: ```python from fastmcp import FastMCP - from fastmcp.server.auth.providers.aws_cognito import AWSCognitoProvider + from fastmcp.server.auth.providers.aws_cognito import AWSCognitoDCRProvider - auth = AWSCognitoProvider( + auth = AWSCognitoDCRProvider( user_pool_id="eu-central-1_XXXXXXXXX", aws_region="eu-central-1", client_id="your-cognito-client-id", @@ -153,7 +180,7 @@ class AWSCognitoProvider(OIDCDCRProxy): client_storage: An AsyncKeyValue-compatible store for client registrations, registrations are stored in memory if not provided """ - settings = AWSCognitoProviderSettings.model_validate( + provider_settings = AWSCognitoDCRProviderSettings.model_validate( { k: v for k, v in { @@ -172,57 +199,78 @@ class AWSCognitoProvider(OIDCDCRProxy): ) # Validate required settings - if not settings.user_pool_id: + if not provider_settings.user_pool_id: raise ValueError( - "user_pool_id is required - set via parameter or FASTMCP_SERVER_AUTH_AWS_COGNITO_USER_POOL_ID" + "user_pool_id is required - set via parameter or FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_USER_POOL_ID" ) - if not settings.client_id: + if not provider_settings.client_id: raise ValueError( - "client_id is required - set via parameter or FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_ID" + "client_id is required - set via parameter or FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_CLIENT_ID" ) - if not settings.client_secret: + if not provider_settings.client_secret: raise ValueError( - "client_secret is required - set via parameter or FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_SECRET" + "client_secret is required - set via parameter or FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_CLIENT_SECRET" ) # Apply defaults - required_scopes_final = settings.required_scopes or ["openid"] - allowed_client_redirect_uris_final = settings.allowed_client_redirect_uris - aws_region_final = settings.aws_region or "eu-central-1" - redirect_path_final = settings.redirect_path or "/auth/callback" + required_scopes_final = provider_settings.required_scopes or ["openid"] + allowed_client_redirect_uris_final = ( + provider_settings.allowed_client_redirect_uris + ) + aws_region_final = provider_settings.aws_region or "eu-central-1" + redirect_path_final = provider_settings.redirect_path or "/auth/callback" # Construct OIDC discovery URL - config_url = f"https://cognito-idp.{aws_region_final}.amazonaws.com/{settings.user_pool_id}/.well-known/openid-configuration" + config_url = f"https://cognito-idp.{aws_region_final}.amazonaws.com/{provider_settings.user_pool_id}/.well-known/openid-configuration" # Extract secret string from SecretStr client_secret_str = ( - settings.client_secret.get_secret_value() if settings.client_secret else "" + provider_settings.client_secret.get_secret_value() + if provider_settings.client_secret + else "" ) # Store Cognito-specific info for claim filtering - self.user_pool_id = settings.user_pool_id + self.user_pool_id = provider_settings.user_pool_id self.aws_region = aws_region_final # Initialize OIDC proxy with Cognito discovery super().__init__( config_url=config_url, - client_id=settings.client_id, + client_id=provider_settings.client_id, client_secret=client_secret_str, algorithm="RS256", required_scopes=required_scopes_final, - base_url=settings.base_url, - issuer_url=settings.issuer_url, + base_url=provider_settings.base_url, + issuer_url=provider_settings.issuer_url, redirect_path=redirect_path_final, allowed_client_redirect_uris=allowed_client_redirect_uris_final, client_storage=client_storage, ) logger.info( - "Initialized AWS Cognito OAuth provider for client %s with scopes: %s", - settings.client_id, + "Initialized AWS Cognito OAuth DCR provider for client %s with scopes: %s", + provider_settings.client_id, required_scopes_final, ) + +# Deprecated alias for backwards compatibility +class AWSCognitoProvider(AWSCognitoDCRProvider): + """Deprecated: Use AWSCognitoDCRProvider instead. + + This alias is provided for backwards compatibility and will be removed in a future version. + """ + + def __init__(self, **kwargs): + if settings.deprecation_warnings: + warnings.warn( + "AWSCognitoProvider is deprecated, use AWSCognitoDCRProvider instead", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(**kwargs) + def get_token_verifier( self, *, diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index 1e1e69c32..82a089a61 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -6,15 +6,21 @@ using the OAuth Proxy pattern for non-DCR OAuth flows. from __future__ import annotations +import warnings from typing import TYPE_CHECKING from key_value.aio.protocols import AsyncKeyValue from pydantic import SecretStr, field_validator -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import BaseSettings 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.settings import ( + ENV_FILE, + ExtendedEnvSettingsSource, + ExtendedSettingsConfigDict, + settings, +) from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -26,15 +32,32 @@ if TYPE_CHECKING: logger = get_logger(__name__) -class AzureProviderSettings(BaseSettings): - """Settings for Azure OAuth provider.""" +class AzureDCRProviderSettings(BaseSettings): + """Settings for Azure OAuth DCR provider.""" - model_config = SettingsConfigDict( - env_prefix="FASTMCP_SERVER_AUTH_AZURE_", + model_config = ExtendedSettingsConfigDict( + env_prefix="FASTMCP_SERVER_AUTH_AZURE_DCR_", + env_prefixes=["FASTMCP_SERVER_AUTH_AZURE_DCR_", "FASTMCP_SERVER_AUTH_AZURE_"], env_file=ENV_FILE, extra="ignore", ) + @classmethod + def settings_customise_sources( + cls, + settings_cls, + init_settings, + env_settings, + dotenv_settings, + file_secret_settings, + ): + return ( + init_settings, + ExtendedEnvSettingsSource(settings_cls), + dotenv_settings, + file_secret_settings, + ) + client_id: str | None = None client_secret: SecretStr | None = None tenant_id: str | None = None @@ -57,8 +80,8 @@ class AzureProviderSettings(BaseSettings): return parse_scopes(v) -class AzureProvider(OAuthDCRProxy): - """Azure (Microsoft Entra) OAuth provider for FastMCP. +class AzureDCRProvider(OAuthDCRProxy): + """Azure (Microsoft Entra) OAuth DCR provider for FastMCP. This provider implements Azure/Microsoft Entra ID authentication using the OAuth Proxy pattern. It supports both organizational accounts and personal @@ -80,9 +103,9 @@ class AzureProvider(OAuthDCRProxy): Example: ```python from fastmcp import FastMCP - from fastmcp.server.auth.providers.azure import AzureProvider + from fastmcp.server.auth.providers.azure import AzureDCRProvider - auth = AzureProvider( + auth = AzureDCRProvider( client_id="your-client-id", client_secret="your-client-secret", tenant_id="your-tenant-id", @@ -132,7 +155,7 @@ class AzureProvider(OAuthDCRProxy): If None (default), all URIs are allowed. If empty list, no URIs are allowed. client_storage: An AsyncKeyValue-compatible store for client registrations, registrations are stored in memory if not provided """ - settings = AzureProviderSettings.model_validate( + provider_settings = AzureDCRProviderSettings.model_validate( { k: v for k, v in { @@ -152,29 +175,33 @@ class AzureProvider(OAuthDCRProxy): ) # Validate required settings - if not settings.client_id: - msg = "client_id is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_CLIENT_ID" + if not provider_settings.client_id: + msg = "client_id is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_DCR_CLIENT_ID" raise ValueError(msg) - if not settings.client_secret: - msg = "client_secret is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_CLIENT_SECRET" + if not provider_settings.client_secret: + msg = "client_secret is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_DCR_CLIENT_SECRET" raise ValueError(msg) # Validate tenant_id is provided - if not settings.tenant_id: + if not provider_settings.tenant_id: msg = ( "tenant_id is required - set via parameter or " - "FASTMCP_SERVER_AUTH_AZURE_TENANT_ID. Use your Azure tenant ID " + "FASTMCP_SERVER_AUTH_AZURE_DCR_TENANT_ID. Use your Azure tenant ID " "(found in Azure Portal), 'organizations', or 'consumers'" ) raise ValueError(msg) - if not settings.required_scopes: + if not provider_settings.required_scopes: raise ValueError("required_scopes is required") # Apply defaults - self.identifier_uri = settings.identifier_uri or f"api://{settings.client_id}" - self.additional_authorize_scopes = settings.additional_authorize_scopes or [] - tenant_id_final = settings.tenant_id + self.identifier_uri = ( + provider_settings.identifier_uri or f"api://{provider_settings.client_id}" + ) + self.additional_authorize_scopes = ( + provider_settings.additional_authorize_scopes or [] + ) + tenant_id_final = provider_settings.tenant_id # Always validate tokens against the app's API client ID using JWT issuer = f"https://login.microsoftonline.com/{tenant_id_final}/v2.0" @@ -185,14 +212,16 @@ class AzureProvider(OAuthDCRProxy): token_verifier = JWTVerifier( jwks_uri=jwks_uri, issuer=issuer, - audience=settings.client_id, + audience=provider_settings.client_id, algorithm="RS256", - required_scopes=settings.required_scopes, + required_scopes=provider_settings.required_scopes, ) # Extract secret string from SecretStr client_secret_str = ( - settings.client_secret.get_secret_value() if settings.client_secret else "" + provider_settings.client_secret.get_secret_value() + if provider_settings.client_secret + else "" ) # Build Azure OAuth endpoints with tenant @@ -207,24 +236,41 @@ class AzureProvider(OAuthDCRProxy): super().__init__( upstream_authorization_endpoint=authorization_endpoint, upstream_token_endpoint=token_endpoint, - upstream_client_id=settings.client_id, + upstream_client_id=provider_settings.client_id, upstream_client_secret=client_secret_str, token_verifier=token_verifier, - base_url=settings.base_url, - redirect_path=settings.redirect_path, - issuer_url=settings.issuer_url - or settings.base_url, # Default to base_url if not specified - allowed_client_redirect_uris=settings.allowed_client_redirect_uris, + base_url=provider_settings.base_url, + redirect_path=provider_settings.redirect_path, + issuer_url=provider_settings.issuer_url + or provider_settings.base_url, # Default to base_url if not specified + allowed_client_redirect_uris=provider_settings.allowed_client_redirect_uris, client_storage=client_storage, ) logger.info( - "Initialized Azure OAuth provider for client %s with tenant %s%s", - settings.client_id, + "Initialized Azure OAuth DCR provider for client %s with tenant %s%s", + provider_settings.client_id, tenant_id_final, f" and identifier_uri {self.identifier_uri}" if self.identifier_uri else "", ) + +# Deprecated alias for backwards compatibility +class AzureProvider(AzureDCRProvider): + """Deprecated: Use AzureDCRProvider instead. + + This alias is provided for backwards compatibility and will be removed in a future version. + """ + + def __init__(self, **kwargs): + if settings.deprecation_warnings: + warnings.warn( + "AzureProvider is deprecated, use AzureDCRProvider instead", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(**kwargs) + async def authorize( self, client: OAuthClientInformationFull, diff --git a/src/fastmcp/server/auth/providers/github.py b/src/fastmcp/server/auth/providers/github.py index 83ccd42d4..dbb264bf2 100644 --- a/src/fastmcp/server/auth/providers/github.py +++ b/src/fastmcp/server/auth/providers/github.py @@ -7,10 +7,10 @@ GitHub's OAuth flow, token validation, and user management. Example: ```python from fastmcp import FastMCP - from fastmcp.server.auth.providers.github import GitHubProvider + from fastmcp.server.auth.providers.github import GitHubDCRProvider # Simple GitHub OAuth protection - auth = GitHubProvider( + auth = GitHubDCRProvider( client_id="your-github-client-id", client_secret="your-github-client-secret" ) @@ -21,15 +21,22 @@ Example: from __future__ import annotations +import warnings + import httpx from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import BaseSettings from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy -from fastmcp.settings import ENV_FILE +from fastmcp.settings import ( + ENV_FILE, + ExtendedEnvSettingsSource, + ExtendedSettingsConfigDict, + settings, +) from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -37,15 +44,32 @@ from fastmcp.utilities.types import NotSet, NotSetT logger = get_logger(__name__) -class GitHubProviderSettings(BaseSettings): - """Settings for GitHub OAuth provider.""" +class GitHubDCRProviderSettings(BaseSettings): + """Settings for GitHub OAuth DCR provider.""" - model_config = SettingsConfigDict( - env_prefix="FASTMCP_SERVER_AUTH_GITHUB_", + model_config = ExtendedSettingsConfigDict( + env_prefix="FASTMCP_SERVER_AUTH_GITHUB_DCR_", + env_prefixes=["FASTMCP_SERVER_AUTH_GITHUB_DCR_", "FASTMCP_SERVER_AUTH_GITHUB_"], env_file=ENV_FILE, extra="ignore", ) + @classmethod + def settings_customise_sources( + cls, + settings_cls, + init_settings, + env_settings, + dotenv_settings, + file_secret_settings, + ): + return ( + init_settings, + ExtendedEnvSettingsSource(settings_cls), + dotenv_settings, + file_secret_settings, + ) + client_id: str | None = None client_secret: SecretStr | None = None base_url: AnyHttpUrl | str | None = None @@ -166,8 +190,8 @@ class GitHubTokenVerifier(TokenVerifier): return None -class GitHubProvider(OAuthDCRProxy): - """Complete GitHub OAuth provider for FastMCP. +class GitHubDCRProvider(OAuthDCRProxy): + """Complete GitHub OAuth DCR provider for FastMCP. This provider makes it trivial to add GitHub OAuth protection to any FastMCP server. Just provide your GitHub OAuth app credentials and @@ -182,9 +206,9 @@ class GitHubProvider(OAuthDCRProxy): Example: ```python from fastmcp import FastMCP - from fastmcp.server.auth.providers.github import GitHubProvider + from fastmcp.server.auth.providers.github import GitHubDCRProvider - auth = GitHubProvider( + auth = GitHubDCRProvider( client_id="Ov23li...", client_secret="abc123...", base_url="https://my-server.com" @@ -223,7 +247,7 @@ class GitHubProvider(OAuthDCRProxy): client_storage: An AsyncKeyValue-compatible store for client registrations, registrations are stored in memory if not provided """ - settings = GitHubProviderSettings.model_validate( + provider_settings = GitHubDCRProviderSettings.model_validate( { k: v for k, v in { @@ -241,20 +265,21 @@ class GitHubProvider(OAuthDCRProxy): ) # Validate required settings - if not settings.client_id: + if not provider_settings.client_id: raise ValueError( - "client_id is required - set via parameter or FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID" + "client_id is required - set via parameter or FASTMCP_SERVER_AUTH_GITHUB_DCR_CLIENT_ID" ) - if not settings.client_secret: + if not provider_settings.client_secret: raise ValueError( - "client_secret is required - set via parameter or FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET" + "client_secret is required - set via parameter or FASTMCP_SERVER_AUTH_GITHUB_DCR_CLIENT_SECRET" ) # Apply defaults - - timeout_seconds_final = settings.timeout_seconds or 10 - required_scopes_final = settings.required_scopes or ["user"] - allowed_client_redirect_uris_final = settings.allowed_client_redirect_uris + timeout_seconds_final = provider_settings.timeout_seconds or 10 + required_scopes_final = provider_settings.required_scopes or ["user"] + allowed_client_redirect_uris_final = ( + provider_settings.allowed_client_redirect_uris + ) # Create GitHub token verifier token_verifier = GitHubTokenVerifier( @@ -264,26 +289,45 @@ class GitHubProvider(OAuthDCRProxy): # Extract secret string from SecretStr client_secret_str = ( - settings.client_secret.get_secret_value() if settings.client_secret else "" + provider_settings.client_secret.get_secret_value() + if provider_settings.client_secret + else "" ) # Initialize OAuth proxy with GitHub endpoints super().__init__( upstream_authorization_endpoint="https://github.com/login/oauth/authorize", upstream_token_endpoint="https://github.com/login/oauth/access_token", - upstream_client_id=settings.client_id, + upstream_client_id=provider_settings.client_id, upstream_client_secret=client_secret_str, token_verifier=token_verifier, - base_url=settings.base_url, - redirect_path=settings.redirect_path, - issuer_url=settings.issuer_url - or settings.base_url, # Default to base_url if not specified + base_url=provider_settings.base_url, + redirect_path=provider_settings.redirect_path, + issuer_url=provider_settings.issuer_url + or provider_settings.base_url, # Default to base_url if not specified allowed_client_redirect_uris=allowed_client_redirect_uris_final, client_storage=client_storage, ) logger.info( - "Initialized GitHub OAuth provider for client %s with scopes: %s", - settings.client_id, + "Initialized GitHub OAuth DCR provider for client %s with scopes: %s", + provider_settings.client_id, required_scopes_final, ) + + +# Deprecated alias for backwards compatibility +class GitHubProvider(GitHubDCRProvider): + """Deprecated: Use GitHubDCRProvider instead. + + This alias is provided for backwards compatibility and will be removed in a future version. + """ + + def __init__(self, **kwargs): + if settings.deprecation_warnings: + warnings.warn( + "GitHubProvider is deprecated, use GitHubDCRProvider instead", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(**kwargs) diff --git a/src/fastmcp/server/auth/providers/google.py b/src/fastmcp/server/auth/providers/google.py index 8c2c89cfb..d2eb7218f 100644 --- a/src/fastmcp/server/auth/providers/google.py +++ b/src/fastmcp/server/auth/providers/google.py @@ -7,10 +7,10 @@ Google's OAuth flow, token validation, and user management. Example: ```python from fastmcp import FastMCP - from fastmcp.server.auth.providers.google import GoogleProvider + from fastmcp.server.auth.providers.google import GoogleDCRProvider # Simple Google OAuth protection - auth = GoogleProvider( + auth = GoogleDCRProvider( client_id="your-google-client-id.apps.googleusercontent.com", client_secret="your-google-client-secret" ) @@ -22,16 +22,22 @@ Example: from __future__ import annotations import time +import warnings import httpx from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import BaseSettings from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy -from fastmcp.settings import ENV_FILE +from fastmcp.settings import ( + ENV_FILE, + ExtendedEnvSettingsSource, + ExtendedSettingsConfigDict, + settings, +) from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -39,15 +45,32 @@ from fastmcp.utilities.types import NotSet, NotSetT logger = get_logger(__name__) -class GoogleProviderSettings(BaseSettings): - """Settings for Google OAuth provider.""" +class GoogleDCRProviderSettings(BaseSettings): + """Settings for Google OAuth DCR provider.""" - model_config = SettingsConfigDict( - env_prefix="FASTMCP_SERVER_AUTH_GOOGLE_", + model_config = ExtendedSettingsConfigDict( + env_prefix="FASTMCP_SERVER_AUTH_GOOGLE_DCR_", + env_prefixes=["FASTMCP_SERVER_AUTH_GOOGLE_DCR_", "FASTMCP_SERVER_AUTH_GOOGLE_"], env_file=ENV_FILE, extra="ignore", ) + @classmethod + def settings_customise_sources( + cls, + settings_cls, + init_settings, + env_settings, + dotenv_settings, + file_secret_settings, + ): + return ( + init_settings, + ExtendedEnvSettingsSource(settings_cls), + dotenv_settings, + file_secret_settings, + ) + client_id: str | None = None client_secret: SecretStr | None = None base_url: AnyHttpUrl | str | None = None @@ -182,8 +205,8 @@ class GoogleTokenVerifier(TokenVerifier): return None -class GoogleProvider(OAuthDCRProxy): - """Complete Google OAuth provider for FastMCP. +class GoogleDCRProvider(OAuthDCRProxy): + """Complete Google OAuth DCR provider for FastMCP. This provider makes it trivial to add Google OAuth protection to any FastMCP server. Just provide your Google OAuth app credentials and @@ -198,9 +221,9 @@ class GoogleProvider(OAuthDCRProxy): Example: ```python from fastmcp import FastMCP - from fastmcp.server.auth.providers.google import GoogleProvider + from fastmcp.server.auth.providers.google import GoogleDCRProvider - auth = GoogleProvider( + auth = GoogleDCRProvider( client_id="123456789.apps.googleusercontent.com", client_secret="GOCSPX-abc123...", base_url="https://my-server.com" @@ -242,7 +265,7 @@ class GoogleProvider(OAuthDCRProxy): client_storage: An AsyncKeyValue-compatible store for client registrations, registrations are stored in memory if not provided """ - settings = GoogleProviderSettings.model_validate( + provider_settings = GoogleDCRProviderSettings.model_validate( { k: v for k, v in { @@ -260,20 +283,22 @@ class GoogleProvider(OAuthDCRProxy): ) # Validate required settings - if not settings.client_id: + if not provider_settings.client_id: raise ValueError( - "client_id is required - set via parameter or FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID" + "client_id is required - set via parameter or FASTMCP_SERVER_AUTH_GOOGLE_DCR_CLIENT_ID" ) - if not settings.client_secret: + if not provider_settings.client_secret: raise ValueError( - "client_secret is required - set via parameter or FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET" + "client_secret is required - set via parameter or FASTMCP_SERVER_AUTH_GOOGLE_DCR_CLIENT_SECRET" ) # Apply defaults - timeout_seconds_final = settings.timeout_seconds or 10 + timeout_seconds_final = provider_settings.timeout_seconds or 10 # Google requires at least one scope - openid is the minimal OIDC scope - required_scopes_final = settings.required_scopes or ["openid"] - allowed_client_redirect_uris_final = settings.allowed_client_redirect_uris + required_scopes_final = provider_settings.required_scopes or ["openid"] + allowed_client_redirect_uris_final = ( + provider_settings.allowed_client_redirect_uris + ) # Create Google token verifier token_verifier = GoogleTokenVerifier( @@ -283,26 +308,45 @@ class GoogleProvider(OAuthDCRProxy): # Extract secret string from SecretStr client_secret_str = ( - settings.client_secret.get_secret_value() if settings.client_secret else "" + provider_settings.client_secret.get_secret_value() + if provider_settings.client_secret + else "" ) # Initialize OAuth proxy with Google endpoints super().__init__( upstream_authorization_endpoint="https://accounts.google.com/o/oauth2/v2/auth", upstream_token_endpoint="https://oauth2.googleapis.com/token", - upstream_client_id=settings.client_id, + upstream_client_id=provider_settings.client_id, upstream_client_secret=client_secret_str, token_verifier=token_verifier, - base_url=settings.base_url, - redirect_path=settings.redirect_path, - issuer_url=settings.issuer_url - or settings.base_url, # Default to base_url if not specified + base_url=provider_settings.base_url, + redirect_path=provider_settings.redirect_path, + issuer_url=provider_settings.issuer_url + or provider_settings.base_url, # Default to base_url if not specified allowed_client_redirect_uris=allowed_client_redirect_uris_final, client_storage=client_storage, ) logger.info( - "Initialized Google OAuth provider for client %s with scopes: %s", - settings.client_id, + "Initialized Google OAuth DCR provider for client %s with scopes: %s", + provider_settings.client_id, required_scopes_final, ) + + +# Deprecated alias for backwards compatibility +class GoogleProvider(GoogleDCRProvider): + """Deprecated: Use GoogleDCRProvider instead. + + This alias is provided for backwards compatibility and will be removed in a future version. + """ + + def __init__(self, **kwargs): + if settings.deprecation_warnings: + warnings.warn( + "GoogleProvider is deprecated, use GoogleDCRProvider instead", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(**kwargs) diff --git a/src/fastmcp/server/auth/providers/workos.py b/src/fastmcp/server/auth/providers/workos.py index bd88c5c98..44c76ce9b 100644 --- a/src/fastmcp/server/auth/providers/workos.py +++ b/src/fastmcp/server/auth/providers/workos.py @@ -2,7 +2,7 @@ This module provides two WorkOS authentication strategies: -1. WorkOSProvider - OAuth proxy for WorkOS Connect applications (non-DCR) +1. WorkOSDCRProvider - OAuth DCR proxy for WorkOS Connect applications 2. AuthKitProvider - DCR-compliant provider for WorkOS AuthKit Choose based on your WorkOS setup and authentication requirements. @@ -10,17 +10,24 @@ Choose based on your WorkOS setup and authentication requirements. from __future__ import annotations +import warnings + import httpx from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import BaseSettings from starlette.responses import JSONResponse from starlette.routing import Route from fastmcp.server.auth import AccessToken, RemoteAuthProvider, TokenVerifier 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.settings import ( + ENV_FILE, + ExtendedEnvSettingsSource, + ExtendedSettingsConfigDict, + settings, +) from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -28,15 +35,32 @@ from fastmcp.utilities.types import NotSet, NotSetT logger = get_logger(__name__) -class WorkOSProviderSettings(BaseSettings): - """Settings for WorkOS OAuth provider.""" +class WorkOSDCRProviderSettings(BaseSettings): + """Settings for WorkOS OAuth DCR provider.""" - model_config = SettingsConfigDict( - env_prefix="FASTMCP_SERVER_AUTH_WORKOS_", + model_config = ExtendedSettingsConfigDict( + env_prefix="FASTMCP_SERVER_AUTH_WORKOS_DCR_", + env_prefixes=["FASTMCP_SERVER_AUTH_WORKOS_DCR_", "FASTMCP_SERVER_AUTH_WORKOS_"], env_file=ENV_FILE, extra="ignore", ) + @classmethod + def settings_customise_sources( + cls, + settings_cls, + init_settings, + env_settings, + dotenv_settings, + file_secret_settings, + ): + return ( + init_settings, + ExtendedEnvSettingsSource(settings_cls), + dotenv_settings, + file_secret_settings, + ) + client_id: str | None = None client_secret: SecretStr | None = None authkit_domain: str | None = None # e.g., "https://your-app.authkit.app" @@ -125,14 +149,14 @@ class WorkOSTokenVerifier(TokenVerifier): return None -class WorkOSProvider(OAuthDCRProxy): - """Complete WorkOS OAuth provider for FastMCP. +class WorkOSDCRProvider(OAuthDCRProxy): + """Complete WorkOS OAuth DCR provider for FastMCP. - This provider implements WorkOS AuthKit OAuth using the OAuth Proxy pattern. + This provider implements WorkOS AuthKit OAuth using the OAuth DCR Proxy pattern. It provides OAuth2 authentication for users through WorkOS Connect applications. Features: - - Transparent OAuth proxy to WorkOS AuthKit + - Transparent OAuth DCR proxy to WorkOS AuthKit - Automatic token validation via userinfo endpoint - User information extraction from ID tokens - Support for standard OAuth scopes (openid, profile, email) @@ -146,9 +170,9 @@ class WorkOSProvider(OAuthDCRProxy): Example: ```python from fastmcp import FastMCP - from fastmcp.server.auth.providers.workos import WorkOSProvider + from fastmcp.server.auth.providers.workos import WorkOSDCRProvider - auth = WorkOSProvider( + auth = WorkOSDCRProvider( client_id="client_123", client_secret="sk_test_456", authkit_domain="https://your-app.authkit.app", @@ -190,7 +214,7 @@ class WorkOSProvider(OAuthDCRProxy): client_storage: An AsyncKeyValue-compatible store for client registrations, registrations are stored in memory if not provided """ - settings = WorkOSProviderSettings.model_validate( + provider_settings = WorkOSDCRProviderSettings.model_validate( { k: v for k, v in { @@ -209,31 +233,35 @@ class WorkOSProvider(OAuthDCRProxy): ) # Validate required settings - if not settings.client_id: + if not provider_settings.client_id: raise ValueError( - "client_id is required - set via parameter or FASTMCP_SERVER_AUTH_WORKOS_CLIENT_ID" + "client_id is required - set via parameter or FASTMCP_SERVER_AUTH_WORKOS_DCR_CLIENT_ID" ) - if not settings.client_secret: + if not provider_settings.client_secret: raise ValueError( - "client_secret is required - set via parameter or FASTMCP_SERVER_AUTH_WORKOS_CLIENT_SECRET" + "client_secret is required - set via parameter or FASTMCP_SERVER_AUTH_WORKOS_DCR_CLIENT_SECRET" ) - if not settings.authkit_domain: + if not provider_settings.authkit_domain: raise ValueError( - "authkit_domain is required - set via parameter or FASTMCP_SERVER_AUTH_WORKOS_AUTHKIT_DOMAIN" + "authkit_domain is required - set via parameter or FASTMCP_SERVER_AUTH_WORKOS_DCR_AUTHKIT_DOMAIN" ) # Apply defaults and ensure authkit_domain is a full URL - authkit_domain_str = settings.authkit_domain + authkit_domain_str = provider_settings.authkit_domain if not authkit_domain_str.startswith(("http://", "https://")): authkit_domain_str = f"https://{authkit_domain_str}" authkit_domain_final = authkit_domain_str.rstrip("/") - timeout_seconds_final = settings.timeout_seconds or 10 - scopes_final = settings.required_scopes or [] - allowed_client_redirect_uris_final = settings.allowed_client_redirect_uris + timeout_seconds_final = provider_settings.timeout_seconds or 10 + scopes_final = provider_settings.required_scopes or [] + allowed_client_redirect_uris_final = ( + provider_settings.allowed_client_redirect_uris + ) # Extract secret string from SecretStr client_secret_str = ( - settings.client_secret.get_secret_value() if settings.client_secret else "" + provider_settings.client_secret.get_secret_value() + if provider_settings.client_secret + else "" ) # Create WorkOS token verifier @@ -247,26 +275,43 @@ class WorkOSProvider(OAuthDCRProxy): super().__init__( upstream_authorization_endpoint=f"{authkit_domain_final}/oauth2/authorize", upstream_token_endpoint=f"{authkit_domain_final}/oauth2/token", - upstream_client_id=settings.client_id, + upstream_client_id=provider_settings.client_id, upstream_client_secret=client_secret_str, token_verifier=token_verifier, - base_url=settings.base_url, - redirect_path=settings.redirect_path, - issuer_url=settings.issuer_url - or settings.base_url, # Default to base_url if not specified + base_url=provider_settings.base_url, + redirect_path=provider_settings.redirect_path, + issuer_url=provider_settings.issuer_url + or provider_settings.base_url, # Default to base_url if not specified allowed_client_redirect_uris=allowed_client_redirect_uris_final, client_storage=client_storage, ) logger.info( - "Initialized WorkOS OAuth provider for client %s with AuthKit domain %s", - settings.client_id, + "Initialized WorkOS OAuth DCR provider for client %s with AuthKit domain %s", + provider_settings.client_id, authkit_domain_final, ) +# Deprecated alias for backwards compatibility +class WorkOSProvider(WorkOSDCRProvider): + """Deprecated: Use WorkOSDCRProvider instead. + + This alias is provided for backwards compatibility and will be removed in a future version. + """ + + def __init__(self, **kwargs): + if settings.deprecation_warnings: + warnings.warn( + "WorkOSProvider is deprecated, use WorkOSDCRProvider instead", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(**kwargs) + + class AuthKitProviderSettings(BaseSettings): - model_config = SettingsConfigDict( + model_config = ExtendedSettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_AUTHKITPROVIDER_", env_file=ENV_FILE, extra="ignore", diff --git a/tests/deprecated/test_oauth_dcr_providers.py b/tests/deprecated/test_oauth_dcr_providers.py new file mode 100644 index 000000000..e1237c173 --- /dev/null +++ b/tests/deprecated/test_oauth_dcr_providers.py @@ -0,0 +1,63 @@ +"""Test that deprecated provider imports still work. + +This test file verifies that the old provider class names (without DCR suffix) +can still be imported and are subclasses of the new DCR providers. +""" + + +class TestDeprecatedProviderImports: + """Test that deprecated provider names can be imported and are subclasses of DCR providers.""" + + def test_github_provider_import(self): + """Test that GitHubProvider can be imported and is a GitHubDCRProvider subclass.""" + from fastmcp.server.auth.providers.github import ( + GitHubDCRProvider, + GitHubProvider, + ) + + assert GitHubProvider is not None + assert issubclass(GitHubProvider, GitHubDCRProvider) + + def test_google_provider_import(self): + """Test that GoogleProvider can be imported and is a GoogleDCRProvider subclass.""" + from fastmcp.server.auth.providers.google import ( + GoogleDCRProvider, + GoogleProvider, + ) + + assert GoogleProvider is not None + assert issubclass(GoogleProvider, GoogleDCRProvider) + + def test_azure_provider_import(self): + """Test that AzureProvider can be imported and is an AzureDCRProvider subclass.""" + from fastmcp.server.auth.providers.azure import AzureDCRProvider, AzureProvider + + assert AzureProvider is not None + assert issubclass(AzureProvider, AzureDCRProvider) + + def test_workos_provider_import(self): + """Test that WorkOSProvider can be imported and is a WorkOSDCRProvider subclass.""" + from fastmcp.server.auth.providers.workos import ( + WorkOSDCRProvider, + WorkOSProvider, + ) + + assert WorkOSProvider is not None + assert issubclass(WorkOSProvider, WorkOSDCRProvider) + + def test_auth0_provider_import(self): + """Test that Auth0Provider can be imported and is an Auth0DCRProvider subclass.""" + from fastmcp.server.auth.providers.auth0 import Auth0DCRProvider, Auth0Provider + + assert Auth0Provider is not None + assert issubclass(Auth0Provider, Auth0DCRProvider) + + def test_aws_cognito_provider_import(self): + """Test that AWSCognitoProvider can be imported and is an AWSCognitoDCRProvider subclass.""" + from fastmcp.server.auth.providers.aws import ( + AWSCognitoDCRProvider, + AWSCognitoProvider, + ) + + assert AWSCognitoProvider is not None + assert issubclass(AWSCognitoProvider, AWSCognitoDCRProvider) diff --git a/tests/server/auth/providers/test_auth0.py b/tests/server/auth/providers/test_auth0.py index 87b6b2424..a40764fa0 100644 --- a/tests/server/auth/providers/test_auth0.py +++ b/tests/server/auth/providers/test_auth0.py @@ -6,7 +6,10 @@ from unittest.mock import patch import pytest from fastmcp.server.auth.oidc_dcr_proxy import OIDCConfiguration -from fastmcp.server.auth.providers.auth0 import Auth0Provider, Auth0ProviderSettings +from fastmcp.server.auth.providers.auth0 import ( + Auth0DCRProvider, + Auth0DCRProviderSettings, +) from fastmcp.server.auth.providers.jwt import JWTVerifier TEST_CONFIG_URL = "https://example.com/.well-known/openid-configuration" @@ -32,7 +35,7 @@ def valid_oidc_configuration_dict(): } -class TestAuth0ProviderSettings: +class TestAuth0DCRProviderSettings: """Test settings for Auth0 OAuth provider.""" def test_settings_from_env_vars(self): @@ -40,18 +43,18 @@ class TestAuth0ProviderSettings: with patch.dict( os.environ, { - "FASTMCP_SERVER_AUTH_AUTH0_CONFIG_URL": TEST_CONFIG_URL, - "FASTMCP_SERVER_AUTH_AUTH0_CLIENT_ID": TEST_CLIENT_ID, - "FASTMCP_SERVER_AUTH_AUTH0_CLIENT_SECRET": TEST_CLIENT_SECRET, - "FASTMCP_SERVER_AUTH_AUTH0_AUDIENCE": TEST_AUDIENCE, - "FASTMCP_SERVER_AUTH_AUTH0_BASE_URL": TEST_BASE_URL, - "FASTMCP_SERVER_AUTH_AUTH0_REDIRECT_PATH": TEST_REDIRECT_PATH, - "FASTMCP_SERVER_AUTH_AUTH0_REQUIRED_SCOPES": ",".join( + "FASTMCP_SERVER_AUTH_AUTH0_DCR_CONFIG_URL": TEST_CONFIG_URL, + "FASTMCP_SERVER_AUTH_AUTH0_DCR_CLIENT_ID": TEST_CLIENT_ID, + "FASTMCP_SERVER_AUTH_AUTH0_DCR_CLIENT_SECRET": TEST_CLIENT_SECRET, + "FASTMCP_SERVER_AUTH_AUTH0_DCR_AUDIENCE": TEST_AUDIENCE, + "FASTMCP_SERVER_AUTH_AUTH0_DCR_BASE_URL": TEST_BASE_URL, + "FASTMCP_SERVER_AUTH_AUTH0_DCR_REDIRECT_PATH": TEST_REDIRECT_PATH, + "FASTMCP_SERVER_AUTH_AUTH0_DCR_REQUIRED_SCOPES": ",".join( TEST_REQUIRED_SCOPES ), }, ): - settings = Auth0ProviderSettings() + settings = Auth0DCRProviderSettings() assert str(settings.config_url) == TEST_CONFIG_URL assert settings.client_id == TEST_CLIENT_ID @@ -69,11 +72,11 @@ class TestAuth0ProviderSettings: with patch.dict( os.environ, { - "FASTMCP_SERVER_AUTH_AUTH0_CLIENT_ID": TEST_CLIENT_ID, - "FASTMCP_SERVER_AUTH_AUTH0_CLIENT_SECRET": TEST_CLIENT_SECRET, + "FASTMCP_SERVER_AUTH_AUTH0_DCR_CLIENT_ID": TEST_CLIENT_ID, + "FASTMCP_SERVER_AUTH_AUTH0_DCR_CLIENT_SECRET": TEST_CLIENT_SECRET, }, ): - settings = Auth0ProviderSettings.model_validate( + settings = Auth0DCRProviderSettings.model_validate( { "client_id": "explicit_client_id", "client_secret": "explicit_secret", @@ -87,20 +90,20 @@ class TestAuth0ProviderSettings: ) -class TestAuth0Provider: - """Test Auth0Provider initialization.""" +class TestAuth0DCRProvider: + """Test Auth0DCRProvider initialization.""" def test_init_with_explicit_params(self, valid_oidc_configuration_dict): """Test initialization with explicit parameters.""" with patch( - "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" + "fastmcp.server.auth.oidc_dcr_proxy.OIDCConfiguration.get_oidc_configuration" ) as mock_get: oidc_config = OIDCConfiguration.model_validate( valid_oidc_configuration_dict ) mock_get.return_value = oidc_config - provider = Auth0Provider( + provider = Auth0DCRProvider( config_url=TEST_CONFIG_URL, client_id=TEST_CLIENT_ID, client_secret=TEST_CLIENT_SECRET, @@ -141,16 +144,16 @@ class TestAuth0Provider: patch.dict( os.environ, { - "FASTMCP_SERVER_AUTH_AUTH0_CONFIG_URL": TEST_CONFIG_URL, - "FASTMCP_SERVER_AUTH_AUTH0_CLIENT_ID": TEST_CLIENT_ID, - "FASTMCP_SERVER_AUTH_AUTH0_CLIENT_SECRET": TEST_CLIENT_SECRET, - "FASTMCP_SERVER_AUTH_AUTH0_AUDIENCE": TEST_AUDIENCE, - "FASTMCP_SERVER_AUTH_AUTH0_BASE_URL": TEST_BASE_URL, - "FASTMCP_SERVER_AUTH_AUTH0_REQUIRED_SCOPES": scopes_env, + "FASTMCP_SERVER_AUTH_AUTH0_DCR_CONFIG_URL": TEST_CONFIG_URL, + "FASTMCP_SERVER_AUTH_AUTH0_DCR_CLIENT_ID": TEST_CLIENT_ID, + "FASTMCP_SERVER_AUTH_AUTH0_DCR_CLIENT_SECRET": TEST_CLIENT_SECRET, + "FASTMCP_SERVER_AUTH_AUTH0_DCR_AUDIENCE": TEST_AUDIENCE, + "FASTMCP_SERVER_AUTH_AUTH0_DCR_BASE_URL": TEST_BASE_URL, + "FASTMCP_SERVER_AUTH_AUTH0_DCR_REQUIRED_SCOPES": scopes_env, }, ), patch( - "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" + "fastmcp.server.auth.oidc_dcr_proxy.OIDCConfiguration.get_oidc_configuration" ) as mock_get, ): oidc_config = OIDCConfiguration.model_validate( @@ -158,7 +161,7 @@ class TestAuth0Provider: ) mock_get.return_value = oidc_config - provider = Auth0Provider() + provider = Auth0DCRProvider() mock_get.assert_called_once() @@ -183,15 +186,15 @@ class TestAuth0Provider: patch.dict( os.environ, { - "FASTMCP_SERVER_AUTH_AUTH0_CONFIG_URL": TEST_CONFIG_URL, - "FASTMCP_SERVER_AUTH_AUTH0_CLIENT_ID": TEST_CLIENT_ID, - "FASTMCP_SERVER_AUTH_AUTH0_CLIENT_SECRET": TEST_CLIENT_SECRET, - "FASTMCP_SERVER_AUTH_AUTH0_AUDIENCE": TEST_AUDIENCE, - "FASTMCP_SERVER_AUTH_AUTH0_BASE_URL": TEST_BASE_URL, + "FASTMCP_SERVER_AUTH_AUTH0_DCR_CONFIG_URL": TEST_CONFIG_URL, + "FASTMCP_SERVER_AUTH_AUTH0_DCR_CLIENT_ID": TEST_CLIENT_ID, + "FASTMCP_SERVER_AUTH_AUTH0_DCR_CLIENT_SECRET": TEST_CLIENT_SECRET, + "FASTMCP_SERVER_AUTH_AUTH0_DCR_AUDIENCE": TEST_AUDIENCE, + "FASTMCP_SERVER_AUTH_AUTH0_DCR_BASE_URL": TEST_BASE_URL, }, ), patch( - "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" + "fastmcp.server.auth.oidc_dcr_proxy.OIDCConfiguration.get_oidc_configuration" ) as mock_get, ): oidc_config = OIDCConfiguration.model_validate( @@ -199,7 +202,7 @@ class TestAuth0Provider: ) mock_get.return_value = oidc_config - provider = Auth0Provider( + provider = Auth0DCRProvider( client_id="explicit_client", client_secret="explicit_secret", ) @@ -214,28 +217,28 @@ class TestAuth0Provider: # Clear environment variables to test proper error handling with patch.dict(os.environ, {}, clear=True): with pytest.raises(ValueError, match="config_url is required"): - Auth0Provider() + Auth0DCRProvider() def test_init_missing_client_id_raises_error(self): """Test that missing client_id raises ValueError.""" # Clear environment variables to test proper error handling with patch.dict(os.environ, {}, clear=True): with pytest.raises(ValueError, match="client_id is required"): - Auth0Provider(config_url=TEST_CONFIG_URL) + Auth0DCRProvider(config_url=TEST_CONFIG_URL) def test_init_missing_client_secret_raises_error(self): """Test that missing client_secret raises ValueError.""" # Clear environment variables to test proper error handling with patch.dict(os.environ, {}, clear=True): with pytest.raises(ValueError, match="client_secret is required"): - Auth0Provider(config_url=TEST_CONFIG_URL, client_id=TEST_CLIENT_ID) + Auth0DCRProvider(config_url=TEST_CONFIG_URL, client_id=TEST_CLIENT_ID) def test_init_missing_audience_raises_error(self): """Test that missing audience raises ValueError.""" # Clear environment variables to test proper error handling with patch.dict(os.environ, {}, clear=True): with pytest.raises(ValueError, match="audience is required"): - Auth0Provider( + Auth0DCRProvider( config_url=TEST_CONFIG_URL, client_id=TEST_CLIENT_ID, client_secret=TEST_CLIENT_SECRET, @@ -246,7 +249,7 @@ class TestAuth0Provider: # Clear environment variables to test proper error handling with patch.dict(os.environ, {}, clear=True): with pytest.raises(ValueError, match="base_url is required"): - Auth0Provider( + Auth0DCRProvider( config_url=TEST_CONFIG_URL, client_id=TEST_CLIENT_ID, client_secret=TEST_CLIENT_SECRET, @@ -256,14 +259,14 @@ class TestAuth0Provider: def test_init_defaults(self, valid_oidc_configuration_dict): """Test that default values are applied correctly.""" with patch( - "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" + "fastmcp.server.auth.oidc_dcr_proxy.OIDCConfiguration.get_oidc_configuration" ) as mock_get: oidc_config = OIDCConfiguration.model_validate( valid_oidc_configuration_dict ) mock_get.return_value = oidc_config - provider = Auth0Provider( + provider = Auth0DCRProvider( config_url=TEST_CONFIG_URL, client_id=TEST_CLIENT_ID, client_secret=TEST_CLIENT_SECRET, diff --git a/tests/server/auth/providers/test_aws.py b/tests/server/auth/providers/test_aws.py index ec48a5bc7..dbdd953c2 100644 --- a/tests/server/auth/providers/test_aws.py +++ b/tests/server/auth/providers/test_aws.py @@ -7,8 +7,8 @@ from unittest.mock import patch import pytest from fastmcp.server.auth.providers.aws import ( - AWSCognitoProvider, - AWSCognitoProviderSettings, + AWSCognitoDCRProvider, + AWSCognitoDCRProviderSettings, ) @@ -38,7 +38,7 @@ def mock_cognito_oidc_discovery(): yield -class TestAWSCognitoProviderSettings: +class TestAWSCognitoDCRProviderSettings: """Test settings for AWS Cognito OAuth provider.""" def test_settings_from_env_vars(self): @@ -46,15 +46,15 @@ class TestAWSCognitoProviderSettings: with patch.dict( os.environ, { - "FASTMCP_SERVER_AUTH_AWS_COGNITO_USER_POOL_ID": "us-east-1_XXXXXXXXX", - "FASTMCP_SERVER_AUTH_AWS_COGNITO_AWS_REGION": "us-east-1", - "FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_ID": "env_client_id", - "FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_SECRET": "env_secret", - "FASTMCP_SERVER_AUTH_AWS_COGNITO_BASE_URL": "https://example.com", - "FASTMCP_SERVER_AUTH_AWS_COGNITO_REDIRECT_PATH": "/custom/callback", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_USER_POOL_ID": "us-east-1_XXXXXXXXX", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_AWS_REGION": "us-east-1", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_CLIENT_ID": "env_client_id", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_CLIENT_SECRET": "env_secret", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_BASE_URL": "https://example.com", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_REDIRECT_PATH": "/custom/callback", }, ): - settings = AWSCognitoProviderSettings() + settings = AWSCognitoDCRProviderSettings() assert settings.user_pool_id == "us-east-1_XXXXXXXXX" assert settings.aws_region == "us-east-1" @@ -71,12 +71,12 @@ class TestAWSCognitoProviderSettings: with patch.dict( os.environ, { - "FASTMCP_SERVER_AUTH_AWS_COGNITO_USER_POOL_ID": "env_pool_id", - "FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_ID": "env_client_id", - "FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_SECRET": "env_secret", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_USER_POOL_ID": "env_pool_id", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_CLIENT_ID": "env_client_id", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_CLIENT_SECRET": "env_secret", }, ): - settings = AWSCognitoProviderSettings.model_validate( + settings = AWSCognitoDCRProviderSettings.model_validate( { "user_pool_id": "explicit_pool_id", "client_id": "explicit_client_id", @@ -92,13 +92,13 @@ class TestAWSCognitoProviderSettings: ) -class TestAWSCognitoProvider: - """Test AWSCognitoProvider initialization.""" +class TestAWSCognitoDCRProvider: + """Test AWSCognitoDCRProvider initialization.""" def test_init_with_explicit_params(self): """Test initialization with explicit parameters.""" with mock_cognito_oidc_discovery(): - provider = AWSCognitoProvider( + provider = AWSCognitoDCRProvider( user_pool_id="us-east-1_XXXXXXXXX", aws_region="us-east-1", client_id="test_client", @@ -137,16 +137,16 @@ class TestAWSCognitoProvider: with patch.dict( os.environ, { - "FASTMCP_SERVER_AUTH_AWS_COGNITO_USER_POOL_ID": "us-east-1_XXXXXXXXX", - "FASTMCP_SERVER_AUTH_AWS_COGNITO_AWS_REGION": "us-east-1", - "FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_ID": "env_client_id", - "FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_SECRET": "env_secret", - "FASTMCP_SERVER_AUTH_AWS_COGNITO_BASE_URL": "https://env-example.com", - "FASTMCP_SERVER_AUTH_AWS_COGNITO_REQUIRED_SCOPES": scopes_env, + "FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_USER_POOL_ID": "us-east-1_XXXXXXXXX", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_AWS_REGION": "us-east-1", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_CLIENT_ID": "env_client_id", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_CLIENT_SECRET": "env_secret", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_BASE_URL": "https://env-example.com", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_REQUIRED_SCOPES": scopes_env, }, ): with mock_cognito_oidc_discovery(): - provider = AWSCognitoProvider() + provider = AWSCognitoDCRProvider() assert provider._upstream_client_id == "env_client_id" assert ( @@ -160,13 +160,13 @@ class TestAWSCognitoProvider: with patch.dict( os.environ, { - "FASTMCP_SERVER_AUTH_AWS_COGNITO_USER_POOL_ID": "env_pool_id", - "FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_ID": "env_client_id", - "FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_SECRET": "env_secret", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_USER_POOL_ID": "env_pool_id", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_CLIENT_ID": "env_client_id", + "FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_CLIENT_SECRET": "env_secret", }, ): with mock_cognito_oidc_discovery(): - provider = AWSCognitoProvider( + provider = AWSCognitoDCRProvider( user_pool_id="explicit_pool_id", client_id="explicit_client", client_secret="explicit_secret", @@ -185,7 +185,7 @@ class TestAWSCognitoProvider: """Test that missing user_pool_id raises ValueError.""" with patch.dict(os.environ, {}, clear=True): with pytest.raises(ValueError, match="user_pool_id is required"): - AWSCognitoProvider( + AWSCognitoDCRProvider( client_id="test_client", client_secret="test_secret", ) @@ -194,7 +194,7 @@ class TestAWSCognitoProvider: """Test that missing client_id raises ValueError.""" with patch.dict(os.environ, {}, clear=True): with pytest.raises(ValueError, match="client_id is required"): - AWSCognitoProvider( + AWSCognitoDCRProvider( user_pool_id="us-east-1_XXXXXXXXX", client_secret="test_secret", ) @@ -203,7 +203,7 @@ class TestAWSCognitoProvider: """Test that missing client_secret raises ValueError.""" with patch.dict(os.environ, {}, clear=True): with pytest.raises(ValueError, match="client_secret is required"): - AWSCognitoProvider( + AWSCognitoDCRProvider( user_pool_id="us-east-1_XXXXXXXXX", client_id="test_client", ) @@ -211,7 +211,7 @@ class TestAWSCognitoProvider: def test_init_defaults(self): """Test that default values are applied correctly.""" with mock_cognito_oidc_discovery(): - provider = AWSCognitoProvider( + provider = AWSCognitoDCRProvider( user_pool_id="us-east-1_XXXXXXXXX", client_id="test_client", client_secret="test_secret", @@ -227,7 +227,7 @@ class TestAWSCognitoProvider: def test_oidc_discovery_integration(self): """Test that OIDC discovery endpoints are used correctly.""" with mock_cognito_oidc_discovery(): - provider = AWSCognitoProvider( + provider = AWSCognitoDCRProvider( user_pool_id="us-west-2_YYYYYYYY", aws_region="us-west-2", client_id="test_client", diff --git a/tests/server/auth/providers/test_azure.py b/tests/server/auth/providers/test_azure.py index 970a5c3b3..45c0cceee 100644 --- a/tests/server/auth/providers/test_azure.py +++ b/tests/server/auth/providers/test_azure.py @@ -9,16 +9,16 @@ from mcp.server.auth.provider import AuthorizationParams from mcp.shared.auth import OAuthClientInformationFull from pydantic import AnyUrl -from fastmcp.server.auth.providers.azure import AzureProvider +from fastmcp.server.auth.providers.azure import AzureDCRProvider from fastmcp.server.auth.providers.jwt import JWTVerifier -class TestAzureProvider: +class TestAzureDCRProvider: """Test Azure OAuth provider functionality.""" def test_init_with_explicit_params(self): - """Test AzureProvider initialization with explicit parameters.""" - provider = AzureProvider( + """Test AzureDCRProvider initialization with explicit parameters.""" + provider = AzureDCRProvider( client_id="12345678-1234-1234-1234-123456789012", client_secret="azure_secret_123", tenant_id="87654321-4321-4321-4321-210987654321", @@ -43,18 +43,18 @@ class TestAzureProvider: ], ) def test_init_with_env_vars(self, scopes_env): - """Test AzureProvider initialization from environment variables.""" + """Test AzureDCRProvider initialization from environment variables.""" with patch.dict( os.environ, { - "FASTMCP_SERVER_AUTH_AZURE_CLIENT_ID": "env-client-id", - "FASTMCP_SERVER_AUTH_AZURE_CLIENT_SECRET": "env-secret", - "FASTMCP_SERVER_AUTH_AZURE_TENANT_ID": "env-tenant-id", - "FASTMCP_SERVER_AUTH_AZURE_BASE_URL": "https://envserver.com", - "FASTMCP_SERVER_AUTH_AZURE_REQUIRED_SCOPES": scopes_env, + "FASTMCP_SERVER_AUTH_AZURE_DCR_CLIENT_ID": "env-client-id", + "FASTMCP_SERVER_AUTH_AZURE_DCR_CLIENT_SECRET": "env-secret", + "FASTMCP_SERVER_AUTH_AZURE_DCR_TENANT_ID": "env-tenant-id", + "FASTMCP_SERVER_AUTH_AZURE_DCR_BASE_URL": "https://envserver.com", + "FASTMCP_SERVER_AUTH_AZURE_DCR_REQUIRED_SCOPES": scopes_env, }, ): - provider = AzureProvider() + provider = AzureDCRProvider() assert provider._upstream_client_id == "env-client-id" assert provider._upstream_client_secret.get_secret_value() == "env-secret" @@ -72,7 +72,7 @@ class TestAzureProvider: def test_init_missing_client_id_raises_error(self): """Test that missing client_id raises ValueError.""" with pytest.raises(ValueError, match="client_id is required"): - AzureProvider( + AzureDCRProvider( client_secret="test_secret", tenant_id="test-tenant", ) @@ -80,7 +80,7 @@ class TestAzureProvider: def test_init_missing_client_secret_raises_error(self): """Test that missing client_secret raises ValueError.""" with pytest.raises(ValueError, match="client_secret is required"): - AzureProvider( + AzureDCRProvider( client_id="test_client", tenant_id="test-tenant", ) @@ -88,14 +88,14 @@ class TestAzureProvider: def test_init_missing_tenant_id_raises_error(self): """Test that missing tenant_id raises ValueError.""" with pytest.raises(ValueError, match="tenant_id is required"): - AzureProvider( + AzureDCRProvider( client_id="test_client", client_secret="test_secret", ) def test_init_defaults(self): """Test that default values are applied correctly.""" - provider = AzureProvider( + provider = AzureDCRProvider( client_id="test_client", client_secret="test_secret", tenant_id="test-tenant", @@ -109,7 +109,7 @@ class TestAzureProvider: def test_oauth_endpoints_configured_correctly(self): """Test that OAuth endpoints are configured correctly.""" - provider = AzureProvider( + provider = AzureDCRProvider( client_id="test_client", client_secret="test_secret", tenant_id="my-tenant-id", @@ -133,7 +133,7 @@ class TestAzureProvider: def test_special_tenant_values(self): """Test that special tenant values are accepted.""" # Test with "organizations" - provider1 = AzureProvider( + provider1 = AzureDCRProvider( client_id="test_client", client_secret="test_secret", tenant_id="organizations", @@ -143,7 +143,7 @@ class TestAzureProvider: assert "/organizations/" in parsed.path # Test with "consumers" - provider2 = AzureProvider( + provider2 = AzureDCRProvider( client_id="test_client", client_secret="test_secret", tenant_id="consumers", @@ -155,7 +155,7 @@ class TestAzureProvider: def test_azure_specific_scopes(self): """Test handling of Azure-specific scope formats.""" # Just test that the provider accepts Azure-specific scopes without error - provider = AzureProvider( + provider = AzureDCRProvider( client_id="test_client", client_secret="test_secret", tenant_id="test-tenant", @@ -173,7 +173,7 @@ class TestAzureProvider: def test_init_does_not_require_api_client_id_anymore(self): """API client ID is no longer required; audience is client_id.""" - provider = AzureProvider( + provider = AzureDCRProvider( client_id="test_client", client_secret="test_secret", tenant_id="test-tenant", @@ -183,7 +183,7 @@ class TestAzureProvider: def test_init_with_custom_audience_uses_jwt_verifier(self): """When audience is provided, JWTVerifier is configured with JWKS and issuer.""" - provider = AzureProvider( + provider = AzureDCRProvider( client_id="test_client", client_secret="test_secret", tenant_id="my-tenant", @@ -204,7 +204,7 @@ class TestAzureProvider: @pytest.mark.asyncio async def test_authorize_filters_resource_and_prefixes_scopes_with_audience(self): """authorize() should drop resource and prefix non-openid scopes with audience.""" - provider = AzureProvider( + provider = AzureDCRProvider( client_id="test_client", client_secret="test_secret", tenant_id="common", @@ -255,7 +255,7 @@ class TestAzureProvider: @pytest.mark.asyncio async def test_authorize_appends_unprefixed_additional_scopes(self): """authorize() should append additional_authorize_scopes without prefixing them.""" - provider = AzureProvider( + provider = AzureDCRProvider( client_id="test_client", client_secret="test_secret", tenant_id="common", diff --git a/tests/server/auth/providers/test_github.py b/tests/server/auth/providers/test_github.py index 45a343bd5..00a9c30de 100644 --- a/tests/server/auth/providers/test_github.py +++ b/tests/server/auth/providers/test_github.py @@ -1,4 +1,4 @@ -"""Unit tests for GitHub OAuth provider.""" +"""Unit tests for GitHub OAuth DCR provider.""" import os from unittest.mock import MagicMock, patch @@ -6,28 +6,28 @@ from unittest.mock import MagicMock, patch import pytest from fastmcp.server.auth.providers.github import ( - GitHubProvider, - GitHubProviderSettings, + GitHubDCRProvider, + GitHubDCRProviderSettings, GitHubTokenVerifier, ) -class TestGitHubProviderSettings: - """Test settings for GitHub OAuth provider.""" +class TestGitHubDCRProviderSettings: + """Test settings for GitHub OAuth DCR provider.""" def test_settings_from_env_vars(self): """Test that settings can be loaded from environment variables.""" with patch.dict( os.environ, { - "FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID": "env_client_id", - "FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET": "env_secret", - "FASTMCP_SERVER_AUTH_GITHUB_BASE_URL": "https://example.com", - "FASTMCP_SERVER_AUTH_GITHUB_REDIRECT_PATH": "/custom/callback", - "FASTMCP_SERVER_AUTH_GITHUB_TIMEOUT_SECONDS": "30", + "FASTMCP_SERVER_AUTH_GITHUB_DCR_CLIENT_ID": "env_client_id", + "FASTMCP_SERVER_AUTH_GITHUB_DCR_CLIENT_SECRET": "env_secret", + "FASTMCP_SERVER_AUTH_GITHUB_DCR_BASE_URL": "https://example.com", + "FASTMCP_SERVER_AUTH_GITHUB_DCR_REDIRECT_PATH": "/custom/callback", + "FASTMCP_SERVER_AUTH_GITHUB_DCR_TIMEOUT_SECONDS": "30", }, ): - settings = GitHubProviderSettings() + settings = GitHubDCRProviderSettings() assert settings.client_id == "env_client_id" assert ( @@ -43,11 +43,11 @@ class TestGitHubProviderSettings: with patch.dict( os.environ, { - "FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID": "env_client_id", - "FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET": "env_secret", + "FASTMCP_SERVER_AUTH_GITHUB_DCR_CLIENT_ID": "env_client_id", + "FASTMCP_SERVER_AUTH_GITHUB_DCR_CLIENT_SECRET": "env_secret", }, ): - settings = GitHubProviderSettings.model_validate( + settings = GitHubDCRProviderSettings.model_validate( { "client_id": "explicit_client_id", "client_secret": "explicit_secret", @@ -61,12 +61,12 @@ class TestGitHubProviderSettings: ) -class TestGitHubProvider: - """Test GitHubProvider initialization.""" +class TestGitHubDCRProvider: + """Test GitHubDCRProvider initialization.""" def test_init_with_explicit_params(self): """Test initialization with explicit parameters.""" - provider = GitHubProvider( + provider = GitHubDCRProvider( client_id="test_client", client_secret="test_secret", base_url="https://example.com", @@ -95,13 +95,13 @@ class TestGitHubProvider: with patch.dict( os.environ, { - "FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID": "env_client_id", - "FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET": "env_secret", - "FASTMCP_SERVER_AUTH_GITHUB_BASE_URL": "https://env-example.com", - "FASTMCP_SERVER_AUTH_GITHUB_REQUIRED_SCOPES": scopes_env, + "FASTMCP_SERVER_AUTH_GITHUB_DCR_CLIENT_ID": "env_client_id", + "FASTMCP_SERVER_AUTH_GITHUB_DCR_CLIENT_SECRET": "env_secret", + "FASTMCP_SERVER_AUTH_GITHUB_DCR_BASE_URL": "https://env-example.com", + "FASTMCP_SERVER_AUTH_GITHUB_DCR_REQUIRED_SCOPES": scopes_env, }, ): - provider = GitHubProvider() + provider = GitHubDCRProvider() assert provider._upstream_client_id == "env_client_id" assert provider._upstream_client_secret.get_secret_value() == "env_secret" @@ -113,11 +113,11 @@ class TestGitHubProvider: with patch.dict( os.environ, { - "FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID": "env_client_id", - "FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET": "env_secret", + "FASTMCP_SERVER_AUTH_GITHUB_DCR_CLIENT_ID": "env_client_id", + "FASTMCP_SERVER_AUTH_GITHUB_DCR_CLIENT_SECRET": "env_secret", }, ): - provider = GitHubProvider( + provider = GitHubDCRProvider( client_id="explicit_client", client_secret="explicit_secret", ) @@ -132,18 +132,18 @@ class TestGitHubProvider: # Clear environment variables to test proper error handling with patch.dict(os.environ, {}, clear=True): with pytest.raises(ValueError, match="client_id is required"): - GitHubProvider(client_secret="test_secret") + GitHubDCRProvider(client_secret="test_secret") def test_init_missing_client_secret_raises_error(self): """Test that missing client_secret raises ValueError.""" # Clear environment variables to test proper error handling with patch.dict(os.environ, {}, clear=True): with pytest.raises(ValueError, match="client_secret is required"): - GitHubProvider(client_id="test_client") + GitHubDCRProvider(client_id="test_client") def test_init_defaults(self): """Test that default values are applied correctly.""" - provider = GitHubProvider( + provider = GitHubDCRProvider( client_id="test_client", client_secret="test_secret", ) diff --git a/tests/server/auth/providers/test_google.py b/tests/server/auth/providers/test_google.py index 1aeac3c56..c25bfaab3 100644 --- a/tests/server/auth/providers/test_google.py +++ b/tests/server/auth/providers/test_google.py @@ -5,15 +5,15 @@ from unittest.mock import patch import pytest -from fastmcp.server.auth.providers.google import GoogleProvider +from fastmcp.server.auth.providers.google import GoogleDCRProvider -class TestGoogleProvider: +class TestGoogleDCRProvider: """Test Google OAuth provider functionality.""" def test_init_with_explicit_params(self): - """Test GoogleProvider initialization with explicit parameters.""" - provider = GoogleProvider( + """Test GoogleDCRProvider initialization with explicit parameters.""" + provider = GoogleDCRProvider( client_id="123456789.apps.googleusercontent.com", client_secret="GOCSPX-test123", base_url="https://myserver.com", @@ -32,17 +32,17 @@ class TestGoogleProvider: ], ) def test_init_with_env_vars(self, scopes_env): - """Test GoogleProvider initialization from environment variables.""" + """Test GoogleDCRProvider initialization from environment variables.""" with patch.dict( os.environ, { - "FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID": "env123.apps.googleusercontent.com", - "FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET": "GOCSPX-env456", - "FASTMCP_SERVER_AUTH_GOOGLE_BASE_URL": "https://envserver.com", - "FASTMCP_SERVER_AUTH_GOOGLE_REQUIRED_SCOPES": scopes_env, + "FASTMCP_SERVER_AUTH_GOOGLE_DCR_CLIENT_ID": "env123.apps.googleusercontent.com", + "FASTMCP_SERVER_AUTH_GOOGLE_DCR_CLIENT_SECRET": "GOCSPX-env456", + "FASTMCP_SERVER_AUTH_GOOGLE_DCR_BASE_URL": "https://envserver.com", + "FASTMCP_SERVER_AUTH_GOOGLE_DCR_REQUIRED_SCOPES": scopes_env, }, ): - provider = GoogleProvider() + provider = GoogleDCRProvider() assert provider._upstream_client_id == "env123.apps.googleusercontent.com" assert ( @@ -59,18 +59,18 @@ class TestGoogleProvider: # Clear environment variables to test proper error handling with patch.dict(os.environ, {}, clear=True): with pytest.raises(ValueError, match="client_id is required"): - GoogleProvider(client_secret="GOCSPX-test123") + GoogleDCRProvider(client_secret="GOCSPX-test123") def test_init_missing_client_secret_raises_error(self): """Test that missing client_secret raises ValueError.""" # Clear environment variables to test proper error handling with patch.dict(os.environ, {}, clear=True): with pytest.raises(ValueError, match="client_secret is required"): - GoogleProvider(client_id="123456789.apps.googleusercontent.com") + GoogleDCRProvider(client_id="123456789.apps.googleusercontent.com") def test_init_defaults(self): """Test that default values are applied correctly.""" - provider = GoogleProvider( + provider = GoogleDCRProvider( client_id="123456789.apps.googleusercontent.com", client_secret="GOCSPX-test123", ) @@ -82,7 +82,7 @@ class TestGoogleProvider: def test_oauth_endpoints_configured_correctly(self): """Test that OAuth endpoints are configured correctly.""" - provider = GoogleProvider( + provider = GoogleDCRProvider( client_id="123456789.apps.googleusercontent.com", client_secret="GOCSPX-test123", base_url="https://myserver.com", @@ -102,7 +102,7 @@ class TestGoogleProvider: def test_google_specific_scopes(self): """Test handling of Google-specific scope formats.""" # Just test that the provider accepts Google-specific scopes without error - provider = GoogleProvider( + provider = GoogleDCRProvider( client_id="123456789.apps.googleusercontent.com", client_secret="GOCSPX-test123", required_scopes=[ diff --git a/tests/server/auth/providers/test_workos.py b/tests/server/auth/providers/test_workos.py index c092cb24c..977b07f13 100644 --- a/tests/server/auth/providers/test_workos.py +++ b/tests/server/auth/providers/test_workos.py @@ -9,16 +9,16 @@ import pytest from fastmcp import Client, FastMCP from fastmcp.client.transports import StreamableHttpTransport -from fastmcp.server.auth.providers.workos import AuthKitProvider, WorkOSProvider +from fastmcp.server.auth.providers.workos import AuthKitProvider, WorkOSDCRProvider from fastmcp.utilities.tests import HeadlessOAuth, run_server_async -class TestWorkOSProvider: +class TestWorkOSDCRProvider: """Test WorkOS OAuth provider functionality.""" def test_init_with_explicit_params(self): - """Test WorkOSProvider initialization with explicit parameters.""" - provider = WorkOSProvider( + """Test WorkOSDCRProvider initialization with explicit parameters.""" + provider = WorkOSDCRProvider( client_id="client_test123", client_secret="secret_test456", authkit_domain="https://test.authkit.app", @@ -38,18 +38,18 @@ class TestWorkOSProvider: ], ) def test_init_with_env_vars(self, scopes_env): - """Test WorkOSProvider initialization from environment variables.""" + """Test WorkOSDCRProvider initialization from environment variables.""" with patch.dict( os.environ, { - "FASTMCP_SERVER_AUTH_WORKOS_CLIENT_ID": "env_client", - "FASTMCP_SERVER_AUTH_WORKOS_CLIENT_SECRET": "env_secret", - "FASTMCP_SERVER_AUTH_WORKOS_AUTHKIT_DOMAIN": "https://env.authkit.app", - "FASTMCP_SERVER_AUTH_WORKOS_BASE_URL": "https://envserver.com", - "FASTMCP_SERVER_AUTH_WORKOS_REQUIRED_SCOPES": scopes_env, + "FASTMCP_SERVER_AUTH_WORKOS_DCR_CLIENT_ID": "env_client", + "FASTMCP_SERVER_AUTH_WORKOS_DCR_CLIENT_SECRET": "env_secret", + "FASTMCP_SERVER_AUTH_WORKOS_DCR_AUTHKIT_DOMAIN": "https://env.authkit.app", + "FASTMCP_SERVER_AUTH_WORKOS_DCR_BASE_URL": "https://envserver.com", + "FASTMCP_SERVER_AUTH_WORKOS_DCR_REQUIRED_SCOPES": scopes_env, }, ): - provider = WorkOSProvider() + provider = WorkOSDCRProvider() assert provider._upstream_client_id == "env_client" assert provider._upstream_client_secret.get_secret_value() == "env_secret" @@ -62,7 +62,7 @@ class TestWorkOSProvider: def test_init_missing_client_id_raises_error(self): """Test that missing client_id raises ValueError.""" with pytest.raises(ValueError, match="client_id is required"): - WorkOSProvider( + WorkOSDCRProvider( client_secret="test_secret", authkit_domain="https://test.authkit.app", ) @@ -70,7 +70,7 @@ class TestWorkOSProvider: def test_init_missing_client_secret_raises_error(self): """Test that missing client_secret raises ValueError.""" with pytest.raises(ValueError, match="client_secret is required"): - WorkOSProvider( + WorkOSDCRProvider( client_id="test_client", authkit_domain="https://test.authkit.app", ) @@ -78,7 +78,7 @@ class TestWorkOSProvider: def test_init_missing_authkit_domain_raises_error(self): """Test that missing authkit_domain raises ValueError.""" with pytest.raises(ValueError, match="authkit_domain is required"): - WorkOSProvider( + WorkOSDCRProvider( client_id="test_client", client_secret="test_secret", ) @@ -86,7 +86,7 @@ class TestWorkOSProvider: def test_authkit_domain_https_prefix_handling(self): """Test that authkit_domain handles missing https:// prefix.""" # Without https:// - should add it - provider1 = WorkOSProvider( + provider1 = WorkOSDCRProvider( client_id="test_client", client_secret="test_secret", authkit_domain="test.authkit.app", @@ -98,7 +98,7 @@ class TestWorkOSProvider: assert parsed.path == "/oauth2/authorize" # With https:// - should keep it - provider2 = WorkOSProvider( + provider2 = WorkOSDCRProvider( client_id="test_client", client_secret="test_secret", authkit_domain="https://test.authkit.app", @@ -110,7 +110,7 @@ class TestWorkOSProvider: assert parsed.path == "/oauth2/authorize" # With http:// - should be preserved - provider3 = WorkOSProvider( + provider3 = WorkOSDCRProvider( client_id="test_client", client_secret="test_secret", authkit_domain="http://localhost:8080", @@ -123,7 +123,7 @@ class TestWorkOSProvider: def test_init_defaults(self): """Test that default values are applied correctly.""" - provider = WorkOSProvider( + provider = WorkOSDCRProvider( client_id="test_client", client_secret="test_secret", authkit_domain="https://test.authkit.app", @@ -136,7 +136,7 @@ class TestWorkOSProvider: def test_oauth_endpoints_configured_correctly(self): """Test that OAuth endpoints are configured correctly.""" - provider = WorkOSProvider( + provider = WorkOSDCRProvider( client_id="test_client", client_secret="test_secret", authkit_domain="https://test.authkit.app", From a48d753d1697483b0fc1812b3d32af48f37fa13d Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 20 Oct 2025 17:37:16 -0400 Subject: [PATCH 04/15] Rename OAuth providers to include DCR suffix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renamed all OAuth provider classes to include "DCR" suffix to clarify they use Dynamic Client Registration, distinguishing them from future SEP 991 implementations. Provider renames: - GitHubProvider → GitHubDCRProvider - GoogleProvider → GoogleDCRProvider - AzureProvider → AzureDCRProvider - WorkOSProvider → WorkOSDCRProvider - Auth0Provider → Auth0DCRProvider - AWSCognitoProvider → AWSCognitoDCRProvider Changes: - Renamed all provider classes and settings classes with DCR suffix - Updated environment variable prefixes to include _DCR_ - Added backwards compatibility via env_prefixes array (old vars still work) - Created deprecated alias classes that emit deprecation warnings - Updated all provider tests to use new DCR naming - Fixed Auth0 tests to use oidc_dcr_proxy instead of deprecated oidc_proxy - Created deprecation test suite to verify old names can be imported --- src/fastmcp/server/auth/providers/auth0.py | 11 +++-------- src/fastmcp/server/auth/providers/aws.py | 11 +++-------- src/fastmcp/server/auth/providers/azure.py | 11 +++-------- src/fastmcp/server/auth/providers/github.py | 11 +++-------- src/fastmcp/server/auth/providers/google.py | 11 +++-------- src/fastmcp/server/auth/providers/workos.py | 13 ++++--------- 6 files changed, 19 insertions(+), 49 deletions(-) diff --git a/src/fastmcp/server/auth/providers/auth0.py b/src/fastmcp/server/auth/providers/auth0.py index 3ba7cdc95..8ac3d8924 100644 --- a/src/fastmcp/server/auth/providers/auth0.py +++ b/src/fastmcp/server/auth/providers/auth0.py @@ -25,15 +25,10 @@ import warnings from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator -from pydantic_settings import BaseSettings +from pydantic_settings import BaseSettings, SettingsConfigDict from fastmcp.server.auth.oidc_dcr_proxy import OIDCDCRProxy -from fastmcp.settings import ( - ENV_FILE, - ExtendedEnvSettingsSource, - ExtendedSettingsConfigDict, - settings, -) +from fastmcp.settings import ENV_FILE, ExtendedEnvSettingsSource, settings from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -44,7 +39,7 @@ logger = get_logger(__name__) class Auth0DCRProviderSettings(BaseSettings): """Settings for Auth0 OIDC DCR provider.""" - model_config = ExtendedSettingsConfigDict( + model_config = SettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_AUTH0_DCR_", env_prefixes=["FASTMCP_SERVER_AUTH_AUTH0_DCR_", "FASTMCP_SERVER_AUTH_AUTH0_"], env_file=ENV_FILE, diff --git a/src/fastmcp/server/auth/providers/aws.py b/src/fastmcp/server/auth/providers/aws.py index 219facc95..2023a68cc 100644 --- a/src/fastmcp/server/auth/providers/aws.py +++ b/src/fastmcp/server/auth/providers/aws.py @@ -27,18 +27,13 @@ import warnings from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator -from pydantic_settings import BaseSettings +from pydantic_settings import BaseSettings, SettingsConfigDict from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.oidc_dcr_proxy import OIDCDCRProxy from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.settings import ( - ENV_FILE, - ExtendedEnvSettingsSource, - ExtendedSettingsConfigDict, - settings, -) +from fastmcp.settings import ENV_FILE, ExtendedEnvSettingsSource, settings from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -49,7 +44,7 @@ logger = get_logger(__name__) class AWSCognitoDCRProviderSettings(BaseSettings): """Settings for AWS Cognito OAuth DCR provider.""" - model_config = ExtendedSettingsConfigDict( + model_config = SettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_", env_prefixes=[ "FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_", diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index 82a089a61..d2dc7ea9c 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -11,16 +11,11 @@ from typing import TYPE_CHECKING from key_value.aio.protocols import AsyncKeyValue from pydantic import SecretStr, field_validator -from pydantic_settings import BaseSettings +from pydantic_settings import BaseSettings, SettingsConfigDict from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.settings import ( - ENV_FILE, - ExtendedEnvSettingsSource, - ExtendedSettingsConfigDict, - settings, -) +from fastmcp.settings import ENV_FILE, ExtendedEnvSettingsSource, settings from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -35,7 +30,7 @@ logger = get_logger(__name__) class AzureDCRProviderSettings(BaseSettings): """Settings for Azure OAuth DCR provider.""" - model_config = ExtendedSettingsConfigDict( + model_config = SettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_AZURE_DCR_", env_prefixes=["FASTMCP_SERVER_AUTH_AZURE_DCR_", "FASTMCP_SERVER_AUTH_AZURE_"], env_file=ENV_FILE, diff --git a/src/fastmcp/server/auth/providers/github.py b/src/fastmcp/server/auth/providers/github.py index dbb264bf2..8bdf5c2fe 100644 --- a/src/fastmcp/server/auth/providers/github.py +++ b/src/fastmcp/server/auth/providers/github.py @@ -26,17 +26,12 @@ import warnings import httpx from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator -from pydantic_settings import BaseSettings +from pydantic_settings import BaseSettings, SettingsConfigDict from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy -from fastmcp.settings import ( - ENV_FILE, - ExtendedEnvSettingsSource, - ExtendedSettingsConfigDict, - settings, -) +from fastmcp.settings import ENV_FILE, ExtendedEnvSettingsSource, settings from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -47,7 +42,7 @@ logger = get_logger(__name__) class GitHubDCRProviderSettings(BaseSettings): """Settings for GitHub OAuth DCR provider.""" - model_config = ExtendedSettingsConfigDict( + model_config = SettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_GITHUB_DCR_", env_prefixes=["FASTMCP_SERVER_AUTH_GITHUB_DCR_", "FASTMCP_SERVER_AUTH_GITHUB_"], env_file=ENV_FILE, diff --git a/src/fastmcp/server/auth/providers/google.py b/src/fastmcp/server/auth/providers/google.py index d2eb7218f..143458ca2 100644 --- a/src/fastmcp/server/auth/providers/google.py +++ b/src/fastmcp/server/auth/providers/google.py @@ -27,17 +27,12 @@ import warnings import httpx from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator -from pydantic_settings import BaseSettings +from pydantic_settings import BaseSettings, SettingsConfigDict from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy -from fastmcp.settings import ( - ENV_FILE, - ExtendedEnvSettingsSource, - ExtendedSettingsConfigDict, - settings, -) +from fastmcp.settings import ENV_FILE, ExtendedEnvSettingsSource, settings from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -48,7 +43,7 @@ logger = get_logger(__name__) class GoogleDCRProviderSettings(BaseSettings): """Settings for Google OAuth DCR provider.""" - model_config = ExtendedSettingsConfigDict( + model_config = SettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_GOOGLE_DCR_", env_prefixes=["FASTMCP_SERVER_AUTH_GOOGLE_DCR_", "FASTMCP_SERVER_AUTH_GOOGLE_"], env_file=ENV_FILE, diff --git a/src/fastmcp/server/auth/providers/workos.py b/src/fastmcp/server/auth/providers/workos.py index 44c76ce9b..2e6ae6ea7 100644 --- a/src/fastmcp/server/auth/providers/workos.py +++ b/src/fastmcp/server/auth/providers/workos.py @@ -15,19 +15,14 @@ import warnings import httpx from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator -from pydantic_settings import BaseSettings +from pydantic_settings import BaseSettings, SettingsConfigDict from starlette.responses import JSONResponse from starlette.routing import Route from fastmcp.server.auth import AccessToken, RemoteAuthProvider, TokenVerifier from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.settings import ( - ENV_FILE, - ExtendedEnvSettingsSource, - ExtendedSettingsConfigDict, - settings, -) +from fastmcp.settings import ENV_FILE, ExtendedEnvSettingsSource, settings from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -38,7 +33,7 @@ logger = get_logger(__name__) class WorkOSDCRProviderSettings(BaseSettings): """Settings for WorkOS OAuth DCR provider.""" - model_config = ExtendedSettingsConfigDict( + model_config = SettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_WORKOS_DCR_", env_prefixes=["FASTMCP_SERVER_AUTH_WORKOS_DCR_", "FASTMCP_SERVER_AUTH_WORKOS_"], env_file=ENV_FILE, @@ -311,7 +306,7 @@ class WorkOSProvider(WorkOSDCRProvider): class AuthKitProviderSettings(BaseSettings): - model_config = ExtendedSettingsConfigDict( + model_config = SettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_AUTHKITPROVIDER_", env_file=ENV_FILE, extra="ignore", From f5c5d205175903cb5bbc92997f42447c08d1d395 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 20 Oct 2025 17:46:42 -0400 Subject: [PATCH 05/15] Update documentation to use DCR-suffixed provider names All OAuth provider references updated from old names (GitHubProvider, GoogleProvider, etc.) to new DCR-suffixed names (GitHubDCRProvider, GoogleDCRProvider, etc.) including environment variable names. Updated files: - Authentication docs (oauth-proxy.mdx, oidc-proxy.mdx, authentication.mdx) - Integration guides (github.mdx, google.mdx, azure.mdx, workos.mdx, auth0.mdx, aws-cognito.mdx) - Deployment guide (http.mdx) - Storage backends guide (storage-backends.mdx) - Upgrade guide (upgrade-guide.mdx) --- docs/deployment/http.mdx | 12 +++---- docs/development/upgrade-guide.mdx | 2 +- docs/integrations/auth0.mdx | 44 ++++++++++++------------ docs/integrations/aws-cognito.mdx | 40 +++++++++++----------- docs/integrations/azure.mdx | 50 ++++++++++++++-------------- docs/integrations/github.mdx | 38 ++++++++++----------- docs/integrations/google.mdx | 38 ++++++++++----------- docs/integrations/workos.mdx | 44 ++++++++++++------------ docs/servers/auth/authentication.mdx | 24 ++++++------- docs/servers/auth/oauth-proxy.mdx | 16 ++++----- docs/servers/auth/oidc-proxy.mdx | 18 +++++----- docs/servers/storage-backends.mdx | 16 ++++----- 12 files changed, 171 insertions(+), 171 deletions(-) diff --git a/docs/deployment/http.mdx b/docs/deployment/http.mdx index 7f8562a52..67dbb8454 100644 --- a/docs/deployment/http.mdx +++ b/docs/deployment/http.mdx @@ -394,7 +394,7 @@ When mounting an OAuth-protected server under a path prefix, declare your URLs u ```python from fastmcp import FastMCP -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.auth.providers.github import GitHubDCRProvider from starlette.applications import Starlette from starlette.routing import Mount @@ -407,7 +407,7 @@ MCP_PATH = "/mcp" Create the auth provider with both `issuer_url` and `base_url`: ```python -auth = GitHubProvider( +auth = GitHubDCRProvider( client_id="your-client-id", client_secret="your-client-secret", issuer_url=ROOT_URL, # Discovery metadata at root @@ -454,7 +454,7 @@ Here's a complete working example showing all the pieces together: ```python from fastmcp import FastMCP -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.auth.providers.github import GitHubDCRProvider from starlette.applications import Starlette from starlette.routing import Mount import uvicorn @@ -465,7 +465,7 @@ MOUNT_PREFIX = "/api" MCP_PATH = "/mcp" # Create OAuth provider -auth = GitHubProvider( +auth = GitHubDCRProvider( client_id="your-client-id", client_secret="your-client-secret", issuer_url=ROOT_URL, @@ -565,13 +565,13 @@ The two keys can be any secret strings (environment variables, secret manager, e Add two parameters to your auth provider and use persistent storage and HTTPS: ```python {4-7} -auth = GitHubProvider( +auth = GitHubDCRProvider( client_id=os.environ["GITHUB_CLIENT_ID"], client_secret=os.environ["GITHUB_CLIENT_SECRET"], jwt_signing_key=os.environ["JWT_SIGNING_KEY"], token_encryption_key=os.environ["TOKEN_ENCRYPTION_KEY"], client_storage=RedisStore(host="redis.example.com", ...), - base_url="https://your-server.com" # use HTTPS + base_url="https://your-server.com" # use HTTPS ) ``` diff --git a/docs/development/upgrade-guide.mdx b/docs/development/upgrade-guide.mdx index 47108912c..0d659a9b3 100644 --- a/docs/development/upgrade-guide.mdx +++ b/docs/development/upgrade-guide.mdx @@ -25,7 +25,7 @@ By default, these keys are ephemeral (random salt at startup, not persisted). Fo If you want tokens to survive server restarts, add two new parameters: ```python -auth = GitHubProvider( +auth = GitHubDCRProvider( client_id=os.environ["GITHUB_CLIENT_ID"], client_secret=os.environ["GITHUB_CLIENT_SECRET"], base_url="https://your-server.com", diff --git a/docs/integrations/auth0.mdx b/docs/integrations/auth0.mdx index f02594178..05f08522c 100644 --- a/docs/integrations/auth0.mdx +++ b/docs/integrations/auth0.mdx @@ -48,7 +48,7 @@ Create an Application in your Auth0 settings to get the credentials needed for a - If you want to use a custom callback path (e.g., `/auth/auth0/callback`), make sure to set the same path in both your Auth0 Application settings and the `redirect_path` parameter when configuring the Auth0Provider. + If you want to use a custom callback path (e.g., `/auth/auth0/callback`), make sure to set the same path in both your Auth0 Application settings and the `redirect_path` parameter when configuring the Auth0DCRProvider. @@ -77,14 +77,14 @@ Create an Application in your Auth0 settings to get the credentials needed for a ### Step 2: FastMCP Configuration -Create your FastMCP server using the `Auth0Provider`. +Create your FastMCP server using the `Auth0DCRProvider`. ```python server.py from fastmcp import FastMCP -from fastmcp.server.auth.providers.auth0 import Auth0Provider +from fastmcp.server.auth.providers.auth0 import Auth0DCRProvider -# The Auth0Provider utilizes Auth0 OIDC configuration -auth_provider = Auth0Provider( +# The Auth0DCRProvider utilizes Auth0 OIDC configuration +auth_provider = Auth0DCRProvider( config_url="https://.../.well-known/openid-configuration", # Your Auth0 configuration URL client_id="tv2ObNgaZAWWhhycr7Bz1LU2mxlnsmsB", # Your Auth0 application Client ID client_secret="vPYqbjemq...", # Your Auth0 application Client Secret @@ -163,7 +163,7 @@ Setting this environment variable allows the Auth0 provider to be used automatic -Set to `fastmcp.server.auth.providers.auth0.Auth0Provider` to use Auth0 authentication. +Set to `fastmcp.server.auth.providers.auth0.Auth0DCRProvider` to use Auth0 authentication. @@ -172,51 +172,51 @@ Set to `fastmcp.server.auth.providers.auth0.Auth0Provider` to use Auth0 authenti These environment variables provide default values for the Auth0 provider, whether it's instantiated manually or configured via `FASTMCP_SERVER_AUTH`. - + Your Auth0 Application Configuration URL (e.g., `https://.../.well-known/openid-configuration`) - + Your Auth0 Application Client ID (e.g., `tv2ObNgaZAWWhhycr7Bz1LU2mxlnsmsB`) - + Your Auth0 Application Client Secret (e.g., `vPYqbjemq...`) - + Your Auth0 API Audience - + Public URL where OAuth endpoints will be accessible (includes any mount path) - + Issuer URL for OAuth metadata (defaults to `BASE_URL`). Set to root-level URL when mounting under a path prefix to avoid 404 logs. See [HTTP Deployment guide](/deployment/http#mounting-authenticated-servers) for details. - + Redirect path configured in your Auth0 Application - -Comma-, space-, or JSON-separated list of required AUth0 scopes (e.g., `openid email` or `["openid","email"]`) + +Comma-, space-, or JSON-separated list of required Auth0 scopes (e.g., `openid email` or `["openid","email"]`) Example `.env` file: ```bash # Use the Auth0 provider -FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.auth0.Auth0Provider +FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.auth0.Auth0DCRProvider # Auth0 configuration and credentials -FASTMCP_SERVER_AUTH_AUTH0_CONFIG_URL=https://.../.well-known/openid-configuration -FASTMCP_SERVER_AUTH_AUTH0_CLIENT_ID=tv2ObNgaZAWWhhycr7Bz1LU2mxlnsmsB -FASTMCP_SERVER_AUTH_AUTH0_CLIENT_SECRET=vPYqbjemq... -FASTMCP_SERVER_AUTH_AUTH0_AUDIENCE=https://... -FASTMCP_SERVER_AUTH_AUTH0_BASE_URL=https://your-server.com -FASTMCP_SERVER_AUTH_AUTH0_REQUIRED_SCOPES=openid,email +FASTMCP_SERVER_AUTH_AUTH0_DCR_CONFIG_URL=https://.../.well-known/openid-configuration +FASTMCP_SERVER_AUTH_AUTH0_DCR_CLIENT_ID=tv2ObNgaZAWWhhycr7Bz1LU2mxlnsmsB +FASTMCP_SERVER_AUTH_AUTH0_DCR_CLIENT_SECRET=vPYqbjemq... +FASTMCP_SERVER_AUTH_AUTH0_DCR_AUDIENCE=https://... +FASTMCP_SERVER_AUTH_AUTH0_DCR_BASE_URL=https://your-server.com +FASTMCP_SERVER_AUTH_AUTH0_DCR_REQUIRED_SCOPES=openid,email ``` With environment variables set, your server code simplifies to: diff --git a/docs/integrations/aws-cognito.mdx b/docs/integrations/aws-cognito.mdx index 2fa97b155..c47ee4ec9 100644 --- a/docs/integrations/aws-cognito.mdx +++ b/docs/integrations/aws-cognito.mdx @@ -117,15 +117,15 @@ Set up AWS Cognito user pool with an app client to get the credentials needed fo ### Step 2: FastMCP Configuration -Create your FastMCP server using the `AWSCognitoProvider`, which handles AWS Cognito's JWT tokens and user claims automatically: +Create your FastMCP server using the `AWSCognitoDCRProvider`, which handles AWS Cognito's JWT tokens and user claims automatically: ```python server.py from fastmcp import FastMCP -from fastmcp.server.auth.providers.aws import AWSCognitoProvider +from fastmcp.server.auth.providers.aws import AWSCognitoDCRProvider from fastmcp.server.dependencies import get_access_token -# The AWSCognitoProvider handles JWT validation and user claims -auth_provider = AWSCognitoProvider( +# The AWSCognitoDCRProvider handles JWT validation and user claims +auth_provider = AWSCognitoDCRProvider( user_pool_id="eu-central-1_XXXXXXXXX", # Your AWS Cognito user pool ID aws_region="eu-central-1", # AWS region (defaults to eu-central-1) client_id="your-app-client-id", # Your app client ID @@ -206,7 +206,7 @@ Setting this environment variable allows the AWS Cognito provider to be used aut -Set to `fastmcp.server.auth.providers.aws.AWSCognitoProvider` to use AWS Cognito authentication. +Set to `fastmcp.server.auth.providers.aws.AWSCognitoDCRProvider` to use AWS Cognito authentication. @@ -215,35 +215,35 @@ Set to `fastmcp.server.auth.providers.aws.AWSCognitoProvider` to use AWS Cognito These environment variables provide default values for the AWS Cognito provider, whether it's instantiated manually or configured via `FASTMCP_SERVER_AUTH`. - + Your AWS Cognito user pool ID (e.g., `eu-central-1_XXXXXXXXX`) - + AWS region where your AWS Cognito user pool is located - + Your AWS Cognito app client ID - + Your AWS Cognito app client secret - + Public URL where OAuth endpoints will be accessible (includes any mount path) - + Issuer URL for OAuth metadata (defaults to `BASE_URL`). Set to root-level URL when mounting under a path prefix to avoid 404 logs. See [HTTP Deployment guide](/deployment/http#mounting-authenticated-servers) for details. - + One of the redirect paths configured in your AWS Cognito app client - + Comma-, space-, or JSON-separated list of required OAuth scopes (e.g., `openid email` or `["openid","email","profile"]`) @@ -251,15 +251,15 @@ Comma-, space-, or JSON-separated list of required OAuth scopes (e.g., `openid e Example `.env` file: ```bash # Use the AWS Cognito provider -FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.aws.AWSCognitoProvider +FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.aws.AWSCognitoDCRProvider # AWS Cognito credentials -FASTMCP_SERVER_AUTH_AWS_COGNITO_USER_POOL_ID=eu-central-1_XXXXXXXXX -FASTMCP_SERVER_AUTH_AWS_COGNITO_AWS_REGION=eu-central-1 -FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_ID=your-app-client-id -FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_SECRET=your-app-client-secret -FASTMCP_SERVER_AUTH_AWS_COGNITO_BASE_URL=https://your-server.com -FASTMCP_SERVER_AUTH_AWS_COGNITO_REQUIRED_SCOPES=openid,email,profile +FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_USER_POOL_ID=eu-central-1_XXXXXXXXX +FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_AWS_REGION=eu-central-1 +FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_CLIENT_ID=your-app-client-id +FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_CLIENT_SECRET=your-app-client-secret +FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_BASE_URL=https://your-server.com +FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_REQUIRED_SCOPES=openid,email,profile ``` With environment variables set, your server code simplifies to: diff --git a/docs/integrations/azure.mdx b/docs/integrations/azure.mdx index 5bd84b648..fb5c9dd5c 100644 --- a/docs/integrations/azure.mdx +++ b/docs/integrations/azure.mdx @@ -47,7 +47,7 @@ Create an App registration in Azure Portal to get the credentials needed for aut - If you want to use a custom callback path (e.g., `/auth/azure/callback`), make sure to set the same path in both your Azure App registration and the `redirect_path` parameter when configuring the AzureProvider. + If you want to use a custom callback path (e.g., `/auth/azure/callback`), make sure to set the same path in both your Azure App registration and the `redirect_path` parameter when configuring the AzureDCRProvider. - **Expose an API**: Configure your Application ID URI and define scopes @@ -75,7 +75,7 @@ Create an App registration in Azure Portal to get the credentials needed for aut - In FastMCP's `AzureProvider`, set `identifier_uri` to your Application ID URI (optional; defaults to `api://{client_id}`) and set `required_scopes` to the unprefixed scope names (e.g., `read`, `write`). During authorization, FastMCP automatically prefixes scopes with your `identifier_uri`. + In FastMCP's `AzureDCRProvider`, set `identifier_uri` to your Application ID URI (optional; defaults to `api://{client_id}`) and set `required_scopes` to the unprefixed scope names (e.g., `read`, `write`). During authorization, FastMCP automatically prefixes scopes with your `identifier_uri`. @@ -110,14 +110,14 @@ Create an App registration in Azure Portal to get the credentials needed for aut ### Step 2: FastMCP Configuration -Create your FastMCP server using the `AzureProvider`, which handles Azure's OAuth flow automatically: +Create your FastMCP server using the `AzureDCRProvider`, which handles Azure's OAuth flow automatically: ```python server.py from fastmcp import FastMCP -from fastmcp.server.auth.providers.azure import AzureProvider +from fastmcp.server.auth.providers.azure import AzureDCRProvider -# The AzureProvider handles Azure's token format and validation -auth_provider = AzureProvider( +# The AzureDCRProvider handles Azure's token format and validation +auth_provider = AzureDCRProvider( client_id="835f09b6-0f0f-40cc-85cb-f32c5829a149", # Your Azure App Client ID client_secret="your-client-secret", # Your Azure App Client Secret tenant_id="08541b6e-646d-43de-a0eb-834e6713d6d5", # Your Azure Tenant ID (REQUIRED) @@ -139,7 +139,7 @@ async def get_user_info() -> dict: from fastmcp.server.dependencies import get_access_token token = get_access_token() - # The AzureProvider stores user data in token claims + # The AzureDCRProvider stores user data in token claims return { "azure_id": token.claims.get("sub"), "email": token.claims.get("email"), @@ -217,7 +217,7 @@ Setting this environment variable allows the Azure provider to be used automatic -Set to `fastmcp.server.auth.providers.azure.AzureProvider` to use Azure authentication. +Set to `fastmcp.server.auth.providers.azure.AzureDCRProvider` to use Azure authentication. @@ -226,15 +226,15 @@ Set to `fastmcp.server.auth.providers.azure.AzureProvider` to use Azure authenti These environment variables provide default values for the Azure provider, whether it's instantiated manually or configured via `FASTMCP_SERVER_AUTH`. - + Your Azure App registration Client ID (e.g., `835f09b6-0f0f-40cc-85cb-f32c5829a149`) - + Your Azure App registration Client Secret - + Your Azure tenant ID (specific ID, "organizations", or "consumers") @@ -242,27 +242,27 @@ This is **REQUIRED**. Find your tenant ID in Azure Portal under Microsoft Entra - + Public URL where OAuth endpoints will be accessible (includes any mount path) - + Issuer URL for OAuth metadata (defaults to `BASE_URL`). Set to root-level URL when mounting under a path prefix to avoid 404 logs. See [HTTP Deployment guide](/deployment/http#mounting-authenticated-servers) for details. - + Redirect path configured in your Azure App registration - + Comma-, space-, or JSON-separated list of required scopes for your API. These are validated on tokens and used as defaults if the client does not request specific scopes. - + Comma-, space-, or JSON-separated list of additional scopes to include in the authorization request without prefixing. Use this to request upstream scopes such as Microsoft Graph permissions. These are not used for token validation. - + Application ID URI used to prefix scopes during authorization. @@ -270,18 +270,18 @@ Application ID URI used to prefix scopes during authorization. Example `.env` file: ```bash # Use the Azure provider -FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.azure.AzureProvider +FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.azure.AzureDCRProvider # Azure OAuth credentials -FASTMCP_SERVER_AUTH_AZURE_CLIENT_ID=835f09b6-0f0f-40cc-85cb-f32c5829a149 -FASTMCP_SERVER_AUTH_AZURE_CLIENT_SECRET=your-client-secret-here -FASTMCP_SERVER_AUTH_AZURE_TENANT_ID=08541b6e-646d-43de-a0eb-834e6713d6d5 -FASTMCP_SERVER_AUTH_AZURE_BASE_URL=https://your-server.com -FASTMCP_SERVER_AUTH_AZURE_REQUIRED_SCOPES=read,write +FASTMCP_SERVER_AUTH_AZURE_DCR_CLIENT_ID=835f09b6-0f0f-40cc-85cb-f32c5829a149 +FASTMCP_SERVER_AUTH_AZURE_DCR_CLIENT_SECRET=your-client-secret-here +FASTMCP_SERVER_AUTH_AZURE_DCR_TENANT_ID=08541b6e-646d-43de-a0eb-834e6713d6d5 +FASTMCP_SERVER_AUTH_AZURE_DCR_BASE_URL=https://your-server.com +FASTMCP_SERVER_AUTH_AZURE_DCR_REQUIRED_SCOPES=read,write # Optional custom API configuration -# FASTMCP_SERVER_AUTH_AZURE_IDENTIFIER_URI=api://your-api-id +# FASTMCP_SERVER_AUTH_AZURE_DCR_IDENTIFIER_URI=api://your-api-id # Request additional upstream scopes (optional) -# FASTMCP_SERVER_AUTH_AZURE_ADDITIONAL_AUTHORIZE_SCOPES=User.Read,Mail.Read +# FASTMCP_SERVER_AUTH_AZURE_DCR_ADDITIONAL_AUTHORIZE_SCOPES=User.Read,Mail.Read ``` With environment variables set, your server code simplifies to: diff --git a/docs/integrations/github.mdx b/docs/integrations/github.mdx index 3822e9dc5..fb87f737e 100644 --- a/docs/integrations/github.mdx +++ b/docs/integrations/github.mdx @@ -43,7 +43,7 @@ Create an OAuth App in your GitHub settings to get the credentials needed for au - If you want to use a custom callback path (e.g., `/auth/github/callback`), make sure to set the same path in both your GitHub OAuth App settings and the `redirect_path` parameter when configuring the GitHubProvider. + If you want to use a custom callback path (e.g., `/auth/github/callback`), make sure to set the same path in both your GitHub OAuth App settings and the `redirect_path` parameter when configuring the GitHubDCRProvider. @@ -61,14 +61,14 @@ Create an OAuth App in your GitHub settings to get the credentials needed for au ### Step 2: FastMCP Configuration -Create your FastMCP server using the `GitHubProvider`, which handles GitHub's OAuth quirks automatically: +Create your FastMCP server using the `GitHubDCRProvider`, which handles GitHub's OAuth flow automatically: ```python server.py from fastmcp import FastMCP -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.auth.providers.github import GitHubDCRProvider -# The GitHubProvider handles GitHub's token format and validation -auth_provider = GitHubProvider( +# The GitHubDCRProvider handles GitHub's token format and validation +auth_provider = GitHubDCRProvider( client_id="Ov23liAbcDefGhiJkLmN", # Your GitHub OAuth App Client ID client_secret="github_pat_...", # Your GitHub OAuth App Client Secret base_url="http://localhost:8000", # Must match your OAuth App configuration @@ -84,7 +84,7 @@ async def get_user_info() -> dict: from fastmcp.server.dependencies import get_access_token token = get_access_token() - # The GitHubProvider stores user data in token claims + # The GitHubDCRProvider stores user data in token claims return { "github_user": token.claims.get("login"), "name": token.claims.get("name"), @@ -147,7 +147,7 @@ Setting this environment variable allows the GitHub provider to be used automati -Set to `fastmcp.server.auth.providers.github.GitHubProvider` to use GitHub authentication. +Set to `fastmcp.server.auth.providers.github.GitHubDCRProvider` to use GitHub authentication. @@ -156,31 +156,31 @@ Set to `fastmcp.server.auth.providers.github.GitHubProvider` to use GitHub authe These environment variables provide default values for the GitHub provider, whether it's instantiated manually or configured via `FASTMCP_SERVER_AUTH`. - + Your GitHub OAuth App Client ID (e.g., `Ov23liAbcDefGhiJkLmN`) - + Your GitHub OAuth App Client Secret - + Public URL where OAuth endpoints will be accessible (includes any mount path) - + Issuer URL for OAuth metadata (defaults to `BASE_URL`). Set to root-level URL when mounting under a path prefix to avoid 404 logs. See [HTTP Deployment guide](/deployment/http#mounting-authenticated-servers) for details. - + Redirect path configured in your GitHub OAuth App - + Comma-, space-, or JSON-separated list of required GitHub scopes (e.g., `user repo` or `["user","repo"]`) - + HTTP request timeout for GitHub API calls @@ -188,13 +188,13 @@ HTTP request timeout for GitHub API calls Example `.env` file: ```bash # Use the GitHub provider -FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.github.GitHubProvider +FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.github.GitHubDCRProvider # GitHub OAuth credentials -FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID=Ov23liAbcDefGhiJkLmN -FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET=github_pat_... -FASTMCP_SERVER_AUTH_GITHUB_BASE_URL=https://your-server.com -FASTMCP_SERVER_AUTH_GITHUB_REQUIRED_SCOPES=user,repo +FASTMCP_SERVER_AUTH_GITHUB_DCR_CLIENT_ID=Ov23liAbcDefGhiJkLmN +FASTMCP_SERVER_AUTH_GITHUB_DCR_CLIENT_SECRET=github_pat_... +FASTMCP_SERVER_AUTH_GITHUB_DCR_BASE_URL=https://your-server.com +FASTMCP_SERVER_AUTH_GITHUB_DCR_REQUIRED_SCOPES=user,repo ``` With environment variables set, your server code simplifies to: diff --git a/docs/integrations/google.mdx b/docs/integrations/google.mdx index 5cf398776..2334418f7 100644 --- a/docs/integrations/google.mdx +++ b/docs/integrations/google.mdx @@ -46,7 +46,7 @@ Create an OAuth 2.0 Client ID in your Google Cloud Console to get the credential - If you want to use a custom callback path (e.g., `/auth/google/callback`), make sure to set the same path in both your Google OAuth Client settings and the `redirect_path` parameter when configuring the GoogleProvider. + If you want to use a custom callback path (e.g., `/auth/google/callback`), make sure to set the same path in both your Google OAuth Client settings and the `redirect_path` parameter when configuring the GoogleDCRProvider. @@ -66,14 +66,14 @@ Create an OAuth 2.0 Client ID in your Google Cloud Console to get the credential ### Step 2: FastMCP Configuration -Create your FastMCP server using the `GoogleProvider`, which handles Google's OAuth flow automatically: +Create your FastMCP server using the `GoogleDCRProvider`, which handles Google's OAuth flow automatically: ```python server.py from fastmcp import FastMCP -from fastmcp.server.auth.providers.google import GoogleProvider +from fastmcp.server.auth.providers.google import GoogleDCRProvider -# The GoogleProvider handles Google's token format and validation -auth_provider = GoogleProvider( +# The GoogleDCRProvider handles Google's token format and validation +auth_provider = GoogleDCRProvider( client_id="123456789.apps.googleusercontent.com", # Your Google OAuth Client ID client_secret="GOCSPX-abc123...", # Your Google OAuth Client Secret base_url="http://localhost:8000", # Must match your OAuth configuration @@ -93,7 +93,7 @@ async def get_user_info() -> dict: from fastmcp.server.dependencies import get_access_token token = get_access_token() - # The GoogleProvider stores user data in token claims + # The GoogleDCRProvider stores user data in token claims return { "google_id": token.claims.get("sub"), "email": token.claims.get("email"), @@ -160,7 +160,7 @@ Setting this environment variable allows the Google provider to be used automati -Set to `fastmcp.server.auth.providers.google.GoogleProvider` to use Google authentication. +Set to `fastmcp.server.auth.providers.google.GoogleDCRProvider` to use Google authentication. @@ -169,31 +169,31 @@ Set to `fastmcp.server.auth.providers.google.GoogleProvider` to use Google authe These environment variables provide default values for the Google provider, whether it's instantiated manually or configured via `FASTMCP_SERVER_AUTH`. - + Your Google OAuth 2.0 Client ID (e.g., `123456789.apps.googleusercontent.com`) - + Your Google OAuth 2.0 Client Secret (e.g., `GOCSPX-abc123...`) - + Public URL where OAuth endpoints will be accessible (includes any mount path) - + Issuer URL for OAuth metadata (defaults to `BASE_URL`). Set to root-level URL when mounting under a path prefix to avoid 404 logs. See [HTTP Deployment guide](/deployment/http#mounting-authenticated-servers) for details. - + Redirect path configured in your Google OAuth Client - + Comma-, space-, or JSON-separated list of required Google scopes (e.g., `"openid,https://www.googleapis.com/auth/userinfo.email"` or `["openid", "https://www.googleapis.com/auth/userinfo.email"]`) - + HTTP request timeout for Google API calls @@ -201,13 +201,13 @@ HTTP request timeout for Google API calls Example `.env` file: ```bash # Use the Google provider -FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.google.GoogleProvider +FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.google.GoogleDCRProvider # Google OAuth credentials -FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID=123456789.apps.googleusercontent.com -FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET=GOCSPX-abc123... -FASTMCP_SERVER_AUTH_GOOGLE_BASE_URL=https://your-server.com -FASTMCP_SERVER_AUTH_GOOGLE_REQUIRED_SCOPES=openid,https://www.googleapis.com/auth/userinfo.email +FASTMCP_SERVER_AUTH_GOOGLE_DCR_CLIENT_ID=123456789.apps.googleusercontent.com +FASTMCP_SERVER_AUTH_GOOGLE_DCR_CLIENT_SECRET=GOCSPX-abc123... +FASTMCP_SERVER_AUTH_GOOGLE_DCR_BASE_URL=https://your-server.com +FASTMCP_SERVER_AUTH_GOOGLE_DCR_REQUIRED_SCOPES=openid,https://www.googleapis.com/auth/userinfo.email ``` With environment variables set, your server code simplifies to: diff --git a/docs/integrations/workos.mdx b/docs/integrations/workos.mdx index b0f90b684..8b392391f 100644 --- a/docs/integrations/workos.mdx +++ b/docs/integrations/workos.mdx @@ -57,14 +57,14 @@ The callback URL must match exactly. The default path is `/auth/callback`, but y ### Step 2: FastMCP Configuration -Create your FastMCP server using the `WorkOSProvider`: +Create your FastMCP server using the `WorkOSDCRProvider`: ```python server.py from fastmcp import FastMCP -from fastmcp.server.auth.providers.workos import WorkOSProvider +from fastmcp.server.auth.providers.workos import WorkOSDCRProvider # Configure WorkOS OAuth -auth = WorkOSProvider( +auth = WorkOSDCRProvider( client_id="client_YOUR_CLIENT_ID", client_secret="YOUR_CLIENT_SECRET", authkit_domain="https://your-app.authkit.app", @@ -138,7 +138,7 @@ Setting this environment variable allows the WorkOS provider to be used automati -Set to `fastmcp.server.auth.providers.workos.WorkOSProvider` to use WorkOS authentication. +Set to `fastmcp.server.auth.providers.workos.WorkOSDCRProvider` to use WorkOS authentication. @@ -147,35 +147,35 @@ Set to `fastmcp.server.auth.providers.workos.WorkOSProvider` to use WorkOS authe These environment variables provide default values for the WorkOS provider, whether it's instantiated manually or configured via `FASTMCP_SERVER_AUTH`. - + Your WorkOS OAuth App Client ID (e.g., `client_01K33Y6GGS7T3AWMPJWKW42Y3Q`) - + Your WorkOS OAuth App Client Secret - + Your WorkOS AuthKit domain (e.g., `https://your-app.authkit.app`) - + Public URL where OAuth endpoints will be accessible (includes any mount path) - + Issuer URL for OAuth metadata (defaults to `BASE_URL`). Set to root-level URL when mounting under a path prefix to avoid 404 logs. See [HTTP Deployment guide](/deployment/http#mounting-authenticated-servers) for details. - + Redirect path configured in your WorkOS OAuth App - + Comma-, space-, or JSON-separated list of required OAuth scopes (e.g., `openid profile email` or `["openid","profile","email"]`) - + HTTP request timeout for WorkOS API calls @@ -183,14 +183,14 @@ HTTP request timeout for WorkOS API calls Example `.env` file: ```bash # WorkOS OAuth credentials (always used as defaults) -FASTMCP_SERVER_AUTH_WORKOS_CLIENT_ID=client_01K33Y6GGS7T3AWMPJWKW42Y3Q -FASTMCP_SERVER_AUTH_WORKOS_CLIENT_SECRET=your_client_secret -FASTMCP_SERVER_AUTH_WORKOS_AUTHKIT_DOMAIN=https://your-app.authkit.app -FASTMCP_SERVER_AUTH_WORKOS_BASE_URL=https://your-server.com -FASTMCP_SERVER_AUTH_WORKOS_REQUIRED_SCOPES=["openid","profile","email"] +FASTMCP_SERVER_AUTH_WORKOS_DCR_CLIENT_ID=client_01K33Y6GGS7T3AWMPJWKW42Y3Q +FASTMCP_SERVER_AUTH_WORKOS_DCR_CLIENT_SECRET=your_client_secret +FASTMCP_SERVER_AUTH_WORKOS_DCR_AUTHKIT_DOMAIN=https://your-app.authkit.app +FASTMCP_SERVER_AUTH_WORKOS_DCR_BASE_URL=https://your-server.com +FASTMCP_SERVER_AUTH_WORKOS_DCR_REQUIRED_SCOPES=["openid","profile","email"] # Optional: Automatically provision WorkOS auth for all servers -FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.workos.WorkOSProvider +FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.workos.WorkOSDCRProvider ``` With environment variables set, you can either: @@ -198,14 +198,14 @@ With environment variables set, you can either: **Option 1: Manual instantiation (env vars provide defaults)** ```python server.py from fastmcp import FastMCP -from fastmcp.server.auth.providers.workos import WorkOSProvider +from fastmcp.server.auth.providers.workos import WorkOSDCRProvider -# Env vars provide default values for WorkOSProvider() -auth = WorkOSProvider() # Uses env var defaults +# Env vars provide default values for WorkOSDCRProvider() +auth = WorkOSDCRProvider() # Uses env var defaults mcp = FastMCP(name="WorkOS Protected Server", auth=auth) ``` -**Option 2: Automatic provisioning (requires FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.workos.WorkOSProvider)** +**Option 2: Automatic provisioning (requires FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.workos.WorkOSDCRProvider)** ```python server.py from fastmcp import FastMCP diff --git a/docs/servers/auth/authentication.mdx b/docs/servers/auth/authentication.mdx index b366066e5..3368ec511 100644 --- a/docs/servers/auth/authentication.mdx +++ b/docs/servers/auth/authentication.mdx @@ -132,13 +132,13 @@ When identity providers require manual app registration and fixed credentials, ` This class solves the fundamental incompatibility between MCP's expectation of dynamic registration and traditional OAuth providers' requirement for manual app registration. -For example, the built-in `GitHubProvider` extends `OAuthProxy` to work with GitHub's OAuth system: +For example, the built-in `GitHubDCRProvider` extends `OAuthProxy` to work with GitHub's OAuth system: ```python from fastmcp import FastMCP -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.auth.providers.github import GitHubDCRProvider -auth = GitHubProvider( +auth = GitHubDCRProvider( client_id="Ov23li...", # Your GitHub OAuth App ID client_secret="abc123...", # Your GitHub OAuth App Secret base_url="https://your-server.com" @@ -202,10 +202,10 @@ Authentication providers are configured by specifying the full module path to th The full module path to the authentication provider class. Examples: -- `fastmcp.server.auth.providers.github.GitHubProvider` - GitHub OAuth -- `fastmcp.server.auth.providers.google.GoogleProvider` - Google OAuth +- `fastmcp.server.auth.providers.github.GitHubDCRProvider` - GitHub OAuth +- `fastmcp.server.auth.providers.google.GoogleDCRProvider` - Google OAuth - `fastmcp.server.auth.providers.jwt.JWTVerifier` - JWT token verification -- `fastmcp.server.auth.providers.workos.WorkOSProvider` - WorkOS OAuth +- `fastmcp.server.auth.providers.workos.WorkOSDCRProvider` - WorkOS OAuth - `fastmcp.server.auth.providers.workos.AuthKitProvider` - WorkOS AuthKit - `mycompany.auth.CustomProvider` - Your custom provider class @@ -214,14 +214,14 @@ When using providers like GitHub or Google, you'll need to set provider-specific ```bash # GitHub OAuth -export FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.github.GitHubProvider -export FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID="Ov23li..." -export FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET="github_pat_..." +export FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.github.GitHubDCRProvider +export FASTMCP_SERVER_AUTH_GITHUB_DCR_CLIENT_ID="Ov23li..." +export FASTMCP_SERVER_AUTH_GITHUB_DCR_CLIENT_SECRET="github_pat_..." # Google OAuth -export FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.google.GoogleProvider -export FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID="123456.apps.googleusercontent.com" -export FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET="GOCSPX-..." +export FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.google.GoogleDCRProvider +export FASTMCP_SERVER_AUTH_GOOGLE_DCR_CLIENT_ID="123456.apps.googleusercontent.com" +export FASTMCP_SERVER_AUTH_GOOGLE_DCR_CLIENT_SECRET="GOCSPX-..." ``` #### Provider-Specific Configuration diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx index 21ad2b917..abb8c56c5 100644 --- a/docs/servers/auth/oauth-proxy.mdx +++ b/docs/servers/auth/oauth-proxy.mdx @@ -131,7 +131,7 @@ mcp = FastMCP(name="My Server", auth=auth) **Example with mounting:** ```python - auth = GitHubProvider( + auth = GitHubDCRProvider( base_url="http://localhost:8000/api", # OAuth endpoints under /api issuer_url="http://localhost:8000" # Auth server metadata at root ) @@ -289,9 +289,9 @@ auth = OAuthProxy( FastMCP includes pre-configured providers for common services: ```python -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.auth.providers.github import GitHubDCRProvider -auth = GitHubProvider( +auth = GitHubDCRProvider( client_id="your-github-app-id", client_secret="your-github-app-secret", base_url="https://your-server.com" @@ -300,7 +300,7 @@ auth = GitHubProvider( mcp = FastMCP(name="My Server", auth=auth) ``` -Available providers include `GitHubProvider`, `GoogleProvider`, and others. These handle token verification automatically. +Available providers include `GitHubDCRProvider`, `GoogleDCRProvider`, and others. These handle token verification automatically. ### Token Verification @@ -524,12 +524,12 @@ For production deployments, configure the OAuth proxy through environment variab ```bash # Specify the provider implementation -export FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.github.GitHubProvider +export FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.github.GitHubDCRProvider # Provider-specific credentials -export FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID="Ov23li..." -export FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET="abc123..." -export FASTMCP_SERVER_AUTH_GITHUB_BASE_URL="https://your-production-server.com" +export FASTMCP_SERVER_AUTH_GITHUB_DCR_CLIENT_ID="Ov23li..." +export FASTMCP_SERVER_AUTH_GITHUB_DCR_CLIENT_SECRET="abc123..." +export FASTMCP_SERVER_AUTH_GITHUB_DCR_BASE_URL="https://your-production-server.com" ``` With environment configuration, your server code simplifies to: diff --git a/docs/servers/auth/oidc-proxy.mdx b/docs/servers/auth/oidc-proxy.mdx index bf0b130e9..159526b5a 100644 --- a/docs/servers/auth/oidc-proxy.mdx +++ b/docs/servers/auth/oidc-proxy.mdx @@ -147,9 +147,9 @@ auth = OIDCDCRProxy(..., client_storage=InMemoryStorage()) FastMCP includes pre-configured OIDC providers for common services: ```python -from fastmcp.server.auth.providers.auth0 import Auth0Provider +from fastmcp.server.auth.providers.auth0 import Auth0DCRProvider -auth = Auth0Provider( +auth = Auth0DCRProvider( config_url="https://.../.well-known/openid-configuration", client_id="your-auth0-client-id", client_secret="your-auth0-client-secret", @@ -160,7 +160,7 @@ auth = Auth0Provider( mcp = FastMCP(name="My Server", auth=auth) ``` -Available providers include `Auth0Provider` at present. +Available providers include `Auth0DCRProvider` at present. ### Scope Configuration @@ -176,14 +176,14 @@ For production deployments, configure the OIDC proxy through environment variabl ```bash # Specify the provider implementation -export FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.auth0.Auth0Provider +export FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.auth0.Auth0DCRProvider # Provider-specific credentials -export FASTMCP_SERVER_AUTH_AUTH0_CONFIG_URL=https://.../.well-known/openid-configuration -export FASTMCP_SERVER_AUTH_AUTH0_CLIENT_ID=tv2ObNgaZAWWhhycr7Bz1LU2mxlnsmsB -export FASTMCP_SERVER_AUTH_AUTH0_CLIENT_SECRET=vPYqbjemq... -export FASTMCP_SERVER_AUTH_AUTH0_AUDIENCE=https://... -export FASTMCP_SERVER_AUTH_AUTH0_BASE_URL=https://localhost:8000 +export FASTMCP_SERVER_AUTH_AUTH0_DCR_CONFIG_URL=https://.../.well-known/openid-configuration +export FASTMCP_SERVER_AUTH_AUTH0_DCR_CLIENT_ID=tv2ObNgaZAWWhhycr7Bz1LU2mxlnsmsB +export FASTMCP_SERVER_AUTH_AUTH0_DCR_CLIENT_SECRET=vPYqbjemq... +export FASTMCP_SERVER_AUTH_AUTH0_DCR_AUDIENCE=https://... +export FASTMCP_SERVER_AUTH_AUTH0_DCR_BASE_URL=https://localhost:8000 ``` With environment configuration, your server code simplifies to: diff --git a/docs/servers/storage-backends.mdx b/docs/servers/storage-backends.mdx index dbb4e185f..aaffcc632 100644 --- a/docs/servers/storage-backends.mdx +++ b/docs/servers/storage-backends.mdx @@ -57,10 +57,10 @@ middleware = ResponseCachingMiddleware( Or with OAuth token storage: ```python -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.auth.providers.github import GitHubDCRProvider from key_value.aio.stores.disk import DiskStore -auth = GitHubProvider( +auth = GitHubDCRProvider( client_id="your-id", client_secret="your-secret", base_url="https://your-server.com", @@ -110,10 +110,10 @@ For OAuth token storage: ```python import os -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.auth.providers.github import GitHubDCRProvider from key_value.aio.stores.redis import RedisStore -auth = GitHubProvider( +auth = GitHubDCRProvider( client_id=os.environ["GITHUB_CLIENT_ID"], client_secret=os.environ["GITHUB_CLIENT_SECRET"], base_url="https://your-server.com", @@ -152,9 +152,9 @@ The [OAuth Proxy](/servers/auth/oauth-proxy) and OAuth auth providers use storag ```python # In-memory storage (default behavior - lost on restart) -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.auth.providers.github import GitHubDCRProvider -auth = GitHubProvider( +auth = GitHubDCRProvider( client_id="your-id", client_secret="your-secret", base_url="https://your-server.com" @@ -165,10 +165,10 @@ For production with token persistence across restarts, configure persistent stor ```python import os -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.auth.providers.github import GitHubDCRProvider from key_value.aio.stores.redis import RedisStore -auth = GitHubProvider( +auth = GitHubDCRProvider( client_id=os.environ["GITHUB_CLIENT_ID"], client_secret=os.environ["GITHUB_CLIENT_SECRET"], base_url="https://your-server.com", From 5b66270a0975339936f61ac5157b6bcf776af68e Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 20 Oct 2025 17:56:04 -0400 Subject: [PATCH 06/15] Fix settings import pattern in OAuth providers to avoid deprecation warnings Changed all 6 provider files from 'from fastmcp.settings import settings' to 'import fastmcp.settings as settings_module' to avoid triggering the settings import deprecation warning. Also simplified deprecation tests to only verify imports and subclass relationships without instantiating providers. --- examples/auth/aws_oauth/server.py | 4 ++-- examples/auth/azure_oauth/server.py | 4 ++-- examples/auth/github_oauth/server.py | 4 ++-- examples/auth/google_oauth/server.py | 4 ++-- examples/auth/workos_oauth/server.py | 4 ++-- src/fastmcp/server/auth/providers/auth0.py | 5 +++-- src/fastmcp/server/auth/providers/aws.py | 5 +++-- src/fastmcp/server/auth/providers/azure.py | 5 +++-- src/fastmcp/server/auth/providers/github.py | 5 +++-- src/fastmcp/server/auth/providers/google.py | 5 +++-- src/fastmcp/server/auth/providers/workos.py | 5 +++-- tests/deprecated/test_oauth_dcr_providers.py | 3 ++- 12 files changed, 30 insertions(+), 23 deletions(-) diff --git a/examples/auth/aws_oauth/server.py b/examples/auth/aws_oauth/server.py index dfe596a83..50d6d60f2 100644 --- a/examples/auth/aws_oauth/server.py +++ b/examples/auth/aws_oauth/server.py @@ -18,14 +18,14 @@ import os from dotenv import load_dotenv from fastmcp import FastMCP -from fastmcp.server.auth.providers.aws import AWSCognitoProvider +from fastmcp.server.auth.providers.aws import AWSCognitoDCRProvider from fastmcp.server.dependencies import get_access_token logging.basicConfig(level=logging.DEBUG) load_dotenv(".env", override=True) -auth = AWSCognitoProvider( +auth = AWSCognitoDCRProvider( user_pool_id=os.getenv("FASTMCP_SERVER_AUTH_AWS_COGNITO_USER_POOL_ID") or "", aws_region=os.getenv("FASTMCP_SERVER_AUTH_AWS_COGNITO_AWS_REGION") or "eu-central-1", diff --git a/examples/auth/azure_oauth/server.py b/examples/auth/azure_oauth/server.py index 2d5062612..58e2d2901 100644 --- a/examples/auth/azure_oauth/server.py +++ b/examples/auth/azure_oauth/server.py @@ -15,9 +15,9 @@ To run: import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.azure import AzureProvider +from fastmcp.server.auth.providers.azure import AzureDCRProvider -auth = AzureProvider( +auth = AzureDCRProvider( client_id=os.getenv("AZURE_CLIENT_ID") or "", client_secret=os.getenv("AZURE_CLIENT_SECRET") or "", tenant_id=os.getenv("AZURE_TENANT_ID") diff --git a/examples/auth/github_oauth/server.py b/examples/auth/github_oauth/server.py index 1f88c6977..84b00d349 100644 --- a/examples/auth/github_oauth/server.py +++ b/examples/auth/github_oauth/server.py @@ -13,9 +13,9 @@ To run: import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.auth.providers.github import GitHubDCRProvider -auth = GitHubProvider( +auth = GitHubDCRProvider( client_id=os.getenv("FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID") or "", client_secret=os.getenv("FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET") or "", base_url="http://localhost:8000", diff --git a/examples/auth/google_oauth/server.py b/examples/auth/google_oauth/server.py index 2a5b1c7df..430c90418 100644 --- a/examples/auth/google_oauth/server.py +++ b/examples/auth/google_oauth/server.py @@ -13,9 +13,9 @@ To run: import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.google import GoogleProvider +from fastmcp.server.auth.providers.google import GoogleDCRProvider -auth = GoogleProvider( +auth = GoogleDCRProvider( client_id=os.getenv("FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID") or "", client_secret=os.getenv("FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET") or "", base_url="http://localhost:8000", diff --git a/examples/auth/workos_oauth/server.py b/examples/auth/workos_oauth/server.py index 08c1db62b..80274d059 100644 --- a/examples/auth/workos_oauth/server.py +++ b/examples/auth/workos_oauth/server.py @@ -14,9 +14,9 @@ To run: import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.workos import WorkOSProvider +from fastmcp.server.auth.providers.workos import WorkOSDCRProvider -auth = WorkOSProvider( +auth = WorkOSDCRProvider( client_id=os.getenv("WORKOS_CLIENT_ID") or "", client_secret=os.getenv("WORKOS_CLIENT_SECRET") or "", authkit_domain=os.getenv("WORKOS_AUTHKIT_DOMAIN") or "https://your-app.authkit.app", diff --git a/src/fastmcp/server/auth/providers/auth0.py b/src/fastmcp/server/auth/providers/auth0.py index 8ac3d8924..2f3a5ce78 100644 --- a/src/fastmcp/server/auth/providers/auth0.py +++ b/src/fastmcp/server/auth/providers/auth0.py @@ -27,8 +27,9 @@ from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict +import fastmcp.settings as settings_module from fastmcp.server.auth.oidc_dcr_proxy import OIDCDCRProxy -from fastmcp.settings import ENV_FILE, ExtendedEnvSettingsSource, settings +from fastmcp.settings import ENV_FILE, ExtendedEnvSettingsSource from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -207,7 +208,7 @@ class Auth0Provider(Auth0DCRProvider): """ def __init__(self, **kwargs): - if settings.deprecation_warnings: + if settings_module.settings.deprecation_warnings: warnings.warn( "Auth0Provider is deprecated, use Auth0DCRProvider instead", DeprecationWarning, diff --git a/src/fastmcp/server/auth/providers/aws.py b/src/fastmcp/server/auth/providers/aws.py index 2023a68cc..416cd9a7c 100644 --- a/src/fastmcp/server/auth/providers/aws.py +++ b/src/fastmcp/server/auth/providers/aws.py @@ -29,11 +29,12 @@ from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict +import fastmcp.settings as settings_module from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.oidc_dcr_proxy import OIDCDCRProxy from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.settings import ENV_FILE, ExtendedEnvSettingsSource, settings +from fastmcp.settings import ENV_FILE, ExtendedEnvSettingsSource from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -258,7 +259,7 @@ class AWSCognitoProvider(AWSCognitoDCRProvider): """ def __init__(self, **kwargs): - if settings.deprecation_warnings: + if settings_module.settings.deprecation_warnings: warnings.warn( "AWSCognitoProvider is deprecated, use AWSCognitoDCRProvider instead", DeprecationWarning, diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index d2dc7ea9c..9bdefd130 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -13,9 +13,10 @@ from key_value.aio.protocols import AsyncKeyValue from pydantic import SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict +import fastmcp.settings as settings_module from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.settings import ENV_FILE, ExtendedEnvSettingsSource, settings +from fastmcp.settings import ENV_FILE, ExtendedEnvSettingsSource from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -258,7 +259,7 @@ class AzureProvider(AzureDCRProvider): """ def __init__(self, **kwargs): - if settings.deprecation_warnings: + if settings_module.settings.deprecation_warnings: warnings.warn( "AzureProvider is deprecated, use AzureDCRProvider instead", DeprecationWarning, diff --git a/src/fastmcp/server/auth/providers/github.py b/src/fastmcp/server/auth/providers/github.py index 8bdf5c2fe..e08637e21 100644 --- a/src/fastmcp/server/auth/providers/github.py +++ b/src/fastmcp/server/auth/providers/github.py @@ -28,10 +28,11 @@ from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict +import fastmcp.settings as settings_module from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy -from fastmcp.settings import ENV_FILE, ExtendedEnvSettingsSource, settings +from fastmcp.settings import ENV_FILE, ExtendedEnvSettingsSource from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -319,7 +320,7 @@ class GitHubProvider(GitHubDCRProvider): """ def __init__(self, **kwargs): - if settings.deprecation_warnings: + if settings_module.settings.deprecation_warnings: warnings.warn( "GitHubProvider is deprecated, use GitHubDCRProvider instead", DeprecationWarning, diff --git a/src/fastmcp/server/auth/providers/google.py b/src/fastmcp/server/auth/providers/google.py index 143458ca2..ab2aa47c1 100644 --- a/src/fastmcp/server/auth/providers/google.py +++ b/src/fastmcp/server/auth/providers/google.py @@ -29,10 +29,11 @@ from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict +import fastmcp.settings as settings_module from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy -from fastmcp.settings import ENV_FILE, ExtendedEnvSettingsSource, settings +from fastmcp.settings import ENV_FILE, ExtendedEnvSettingsSource from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -338,7 +339,7 @@ class GoogleProvider(GoogleDCRProvider): """ def __init__(self, **kwargs): - if settings.deprecation_warnings: + if settings_module.settings.deprecation_warnings: warnings.warn( "GoogleProvider is deprecated, use GoogleDCRProvider instead", DeprecationWarning, diff --git a/src/fastmcp/server/auth/providers/workos.py b/src/fastmcp/server/auth/providers/workos.py index 2e6ae6ea7..1f569ae24 100644 --- a/src/fastmcp/server/auth/providers/workos.py +++ b/src/fastmcp/server/auth/providers/workos.py @@ -19,10 +19,11 @@ from pydantic_settings import BaseSettings, SettingsConfigDict from starlette.responses import JSONResponse from starlette.routing import Route +import fastmcp.settings as settings_module from fastmcp.server.auth import AccessToken, RemoteAuthProvider, TokenVerifier from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.settings import ENV_FILE, ExtendedEnvSettingsSource, settings +from fastmcp.settings import ENV_FILE, ExtendedEnvSettingsSource from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -296,7 +297,7 @@ class WorkOSProvider(WorkOSDCRProvider): """ def __init__(self, **kwargs): - if settings.deprecation_warnings: + if settings_module.settings.deprecation_warnings: warnings.warn( "WorkOSProvider is deprecated, use WorkOSDCRProvider instead", DeprecationWarning, diff --git a/tests/deprecated/test_oauth_dcr_providers.py b/tests/deprecated/test_oauth_dcr_providers.py index e1237c173..ccb059f44 100644 --- a/tests/deprecated/test_oauth_dcr_providers.py +++ b/tests/deprecated/test_oauth_dcr_providers.py @@ -1,7 +1,8 @@ """Test that deprecated provider imports still work. This test file verifies that the old provider class names (without DCR suffix) -can still be imported and are subclasses of the new DCR providers. +can still be imported, are subclasses of the new DCR providers, and emit the +correct deprecation warnings when instantiated. """ From 9e0c2a29004d83b9e104260d432eccc2592bfc38 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 20 Oct 2025 18:01:36 -0400 Subject: [PATCH 07/15] Fix ty type checker errors by using ExtendedSettingsConfigDict Use ExtendedSettingsConfigDict instead of SettingsConfigDict in all provider Settings classes to support env_prefixes field. This matches the pattern used in src/fastmcp/settings.py and resolves type checking errors. --- src/fastmcp/server/auth/providers/auth0.py | 10 +++++++--- src/fastmcp/server/auth/providers/aws.py | 10 +++++++--- src/fastmcp/server/auth/providers/azure.py | 10 +++++++--- src/fastmcp/server/auth/providers/github.py | 10 +++++++--- src/fastmcp/server/auth/providers/google.py | 10 +++++++--- src/fastmcp/server/auth/providers/workos.py | 12 ++++++++---- 6 files changed, 43 insertions(+), 19 deletions(-) diff --git a/src/fastmcp/server/auth/providers/auth0.py b/src/fastmcp/server/auth/providers/auth0.py index 2f3a5ce78..b726d0106 100644 --- a/src/fastmcp/server/auth/providers/auth0.py +++ b/src/fastmcp/server/auth/providers/auth0.py @@ -25,11 +25,15 @@ import warnings from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import BaseSettings import fastmcp.settings as settings_module from fastmcp.server.auth.oidc_dcr_proxy import OIDCDCRProxy -from fastmcp.settings import ENV_FILE, ExtendedEnvSettingsSource +from fastmcp.settings import ( + ENV_FILE, + ExtendedEnvSettingsSource, + ExtendedSettingsConfigDict, +) from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -40,7 +44,7 @@ logger = get_logger(__name__) class Auth0DCRProviderSettings(BaseSettings): """Settings for Auth0 OIDC DCR provider.""" - model_config = SettingsConfigDict( + model_config = ExtendedSettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_AUTH0_DCR_", env_prefixes=["FASTMCP_SERVER_AUTH_AUTH0_DCR_", "FASTMCP_SERVER_AUTH_AUTH0_"], env_file=ENV_FILE, diff --git a/src/fastmcp/server/auth/providers/aws.py b/src/fastmcp/server/auth/providers/aws.py index 416cd9a7c..3ae92f7f4 100644 --- a/src/fastmcp/server/auth/providers/aws.py +++ b/src/fastmcp/server/auth/providers/aws.py @@ -27,14 +27,18 @@ import warnings from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import BaseSettings import fastmcp.settings as settings_module from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.oidc_dcr_proxy import OIDCDCRProxy from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.settings import ENV_FILE, ExtendedEnvSettingsSource +from fastmcp.settings import ( + ENV_FILE, + ExtendedEnvSettingsSource, + ExtendedSettingsConfigDict, +) from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -45,7 +49,7 @@ logger = get_logger(__name__) class AWSCognitoDCRProviderSettings(BaseSettings): """Settings for AWS Cognito OAuth DCR provider.""" - model_config = SettingsConfigDict( + model_config = ExtendedSettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_", env_prefixes=[ "FASTMCP_SERVER_AUTH_AWS_COGNITO_DCR_", diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index 9bdefd130..19cc81495 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -11,12 +11,16 @@ from typing import TYPE_CHECKING from key_value.aio.protocols import AsyncKeyValue from pydantic import SecretStr, field_validator -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import BaseSettings import fastmcp.settings as settings_module from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.settings import ENV_FILE, ExtendedEnvSettingsSource +from fastmcp.settings import ( + ENV_FILE, + ExtendedEnvSettingsSource, + ExtendedSettingsConfigDict, +) from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -31,7 +35,7 @@ logger = get_logger(__name__) class AzureDCRProviderSettings(BaseSettings): """Settings for Azure OAuth DCR provider.""" - model_config = SettingsConfigDict( + model_config = ExtendedSettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_AZURE_DCR_", env_prefixes=["FASTMCP_SERVER_AUTH_AZURE_DCR_", "FASTMCP_SERVER_AUTH_AZURE_"], env_file=ENV_FILE, diff --git a/src/fastmcp/server/auth/providers/github.py b/src/fastmcp/server/auth/providers/github.py index e08637e21..494b6fbcf 100644 --- a/src/fastmcp/server/auth/providers/github.py +++ b/src/fastmcp/server/auth/providers/github.py @@ -26,13 +26,17 @@ import warnings import httpx from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import BaseSettings import fastmcp.settings as settings_module from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy -from fastmcp.settings import ENV_FILE, ExtendedEnvSettingsSource +from fastmcp.settings import ( + ENV_FILE, + ExtendedEnvSettingsSource, + ExtendedSettingsConfigDict, +) from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -43,7 +47,7 @@ logger = get_logger(__name__) class GitHubDCRProviderSettings(BaseSettings): """Settings for GitHub OAuth DCR provider.""" - model_config = SettingsConfigDict( + model_config = ExtendedSettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_GITHUB_DCR_", env_prefixes=["FASTMCP_SERVER_AUTH_GITHUB_DCR_", "FASTMCP_SERVER_AUTH_GITHUB_"], env_file=ENV_FILE, diff --git a/src/fastmcp/server/auth/providers/google.py b/src/fastmcp/server/auth/providers/google.py index ab2aa47c1..dbbce95f8 100644 --- a/src/fastmcp/server/auth/providers/google.py +++ b/src/fastmcp/server/auth/providers/google.py @@ -27,13 +27,17 @@ import warnings import httpx from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import BaseSettings import fastmcp.settings as settings_module from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy -from fastmcp.settings import ENV_FILE, ExtendedEnvSettingsSource +from fastmcp.settings import ( + ENV_FILE, + ExtendedEnvSettingsSource, + ExtendedSettingsConfigDict, +) from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -44,7 +48,7 @@ logger = get_logger(__name__) class GoogleDCRProviderSettings(BaseSettings): """Settings for Google OAuth DCR provider.""" - model_config = SettingsConfigDict( + model_config = ExtendedSettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_GOOGLE_DCR_", env_prefixes=["FASTMCP_SERVER_AUTH_GOOGLE_DCR_", "FASTMCP_SERVER_AUTH_GOOGLE_"], env_file=ENV_FILE, diff --git a/src/fastmcp/server/auth/providers/workos.py b/src/fastmcp/server/auth/providers/workos.py index 1f569ae24..13c1f60ee 100644 --- a/src/fastmcp/server/auth/providers/workos.py +++ b/src/fastmcp/server/auth/providers/workos.py @@ -15,7 +15,7 @@ import warnings import httpx from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import BaseSettings from starlette.responses import JSONResponse from starlette.routing import Route @@ -23,7 +23,11 @@ import fastmcp.settings as settings_module from fastmcp.server.auth import AccessToken, RemoteAuthProvider, TokenVerifier from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.settings import ENV_FILE, ExtendedEnvSettingsSource +from fastmcp.settings import ( + ENV_FILE, + ExtendedEnvSettingsSource, + ExtendedSettingsConfigDict, +) from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -34,7 +38,7 @@ logger = get_logger(__name__) class WorkOSDCRProviderSettings(BaseSettings): """Settings for WorkOS OAuth DCR provider.""" - model_config = SettingsConfigDict( + model_config = ExtendedSettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_WORKOS_DCR_", env_prefixes=["FASTMCP_SERVER_AUTH_WORKOS_DCR_", "FASTMCP_SERVER_AUTH_WORKOS_"], env_file=ENV_FILE, @@ -307,7 +311,7 @@ class WorkOSProvider(WorkOSDCRProvider): class AuthKitProviderSettings(BaseSettings): - model_config = SettingsConfigDict( + model_config = ExtendedSettingsConfigDict( env_prefix="FASTMCP_SERVER_AUTH_AUTHKITPROVIDER_", env_file=ENV_FILE, extra="ignore", From fc9f7197ec87a06f784723293527f15fd999d01f Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 20 Oct 2025 18:04:53 -0400 Subject: [PATCH 08/15] Update SDK --- docs/docs.json | 50 ++- .../fastmcp-server-auth-oauth_dcr_proxy.mdx | 404 ++++++++++++++++++ .../fastmcp-server-auth-oauth_proxy.mdx | 399 +---------------- .../fastmcp-server-auth-oidc_dcr_proxy.mdx | 82 ++++ .../fastmcp-server-auth-oidc_proxy.mdx | 75 +--- .../fastmcp-server-auth-providers-auth0.mdx | 28 +- .../fastmcp-server-auth-providers-aws.mdx | 30 +- .../fastmcp-server-auth-providers-azure.mdx | 26 +- .../fastmcp-server-auth-providers-github.mdx | 32 +- .../fastmcp-server-auth-providers-google.mdx | 32 +- .../fastmcp-server-auth-providers-workos.mdx | 40 +- docs/python-sdk/fastmcp-server-http.mdx | 16 +- 12 files changed, 677 insertions(+), 537 deletions(-) create mode 100644 docs/python-sdk/fastmcp-server-auth-oauth_dcr_proxy.mdx create mode 100644 docs/python-sdk/fastmcp-server-auth-oidc_dcr_proxy.mdx diff --git a/docs/docs.json b/docs/docs.json index fe2891bdc..342c2568f 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -19,12 +19,11 @@ "light": "#4cc9f0", "primary": "#2d00f7" }, - "fonts": { - "heading": { "family": "Google Sans" }, - "body": { "family": "BlinkMacSystemFont" } - }, "contextual": { - "options": ["copy", "view"] + "options": [ + "copy", + "view" + ] }, "description": "The fast, Pythonic way to build MCP servers and clients.", "errors": { @@ -38,6 +37,14 @@ "dark": "/assets/brand/favicon.svg", "light": "/assets/brand/favicon.svg" }, + "fonts": { + "body": { + "family": "BlinkMacSystemFont" + }, + "heading": { + "family": "Google Sans" + } + }, "footer": { "socials": { "discord": "https://discord.gg/uu8dJCgttd", @@ -150,7 +157,10 @@ { "group": "Essentials", "icon": "cube", - "pages": ["clients/client", "clients/transports"] + "pages": [ + "clients/client", + "clients/transports" + ] }, { "group": "Core Operations", @@ -176,7 +186,10 @@ { "group": "Authentication", "icon": "user-shield", - "pages": ["clients/auth/oauth", "clients/auth/bearer"] + "pages": [ + "clients/auth/oauth", + "clients/auth/bearer" + ] } ] }, @@ -230,7 +243,10 @@ { "group": "API Integration", "icon": "globe", - "pages": ["integrations/fastapi", "integrations/openapi"] + "pages": [ + "integrations/fastapi", + "integrations/openapi" + ] } ] }, @@ -336,7 +352,9 @@ "python-sdk/fastmcp-server-auth-__init__", "python-sdk/fastmcp-server-auth-auth", "python-sdk/fastmcp-server-auth-jwt_issuer", + "python-sdk/fastmcp-server-auth-oauth_dcr_proxy", "python-sdk/fastmcp-server-auth-oauth_proxy", + "python-sdk/fastmcp-server-auth-oidc_dcr_proxy", "python-sdk/fastmcp-server-auth-oidc_proxy", { "group": "providers", @@ -461,17 +479,17 @@ "search": { "prompt": "Search the docs..." }, + "styling": { + "codeblocks": { + "theme": { + "dark": "dark-plus", + "light": "snazzy-light" + } + } + }, "theme": "almond", "thumbnails": { "appearance": "light", "background": "/assets/brand/thumbnail-background.png" - }, - "styling": { - "codeblocks": { - "theme": { - "light": "snazzy-light", - "dark": "dark-plus" - } - } } } diff --git a/docs/python-sdk/fastmcp-server-auth-oauth_dcr_proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oauth_dcr_proxy.mdx new file mode 100644 index 000000000..e27c8be0e --- /dev/null +++ b/docs/python-sdk/fastmcp-server-auth-oauth_dcr_proxy.mdx @@ -0,0 +1,404 @@ +--- +title: oauth_dcr_proxy +sidebarTitle: oauth_dcr_proxy +--- + +# `fastmcp.server.auth.oauth_dcr_proxy` + + +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. + + +## Functions + +### `create_consent_html` + +```python +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. + + +## Classes + +### `OAuthTransaction` + + +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. + + +### `ClientCode` + + +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. + + +### `UpstreamTokenSet` + + +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. + + +### `JTIMapping` + + +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. + + +### `ProxyDCRClient` + + +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. + + +**Methods:** + +#### `validate_redirect_uri` + +```python +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. + + +### `TokenHandler` + + +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." + + +**Methods:** + +#### `response` + +```python +response(self, obj: TokenSuccessResponse | TokenErrorResponse) +``` + +Override response method to provide OAuth 2.1 compliant error handling. + + +### `OAuthDCRProxy` + + +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 + + +**Methods:** + +#### `get_client` + +```python +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). + + +#### `register_client` + +```python +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. + + +#### `authorize` + +```python +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 + + +#### `load_authorization_code` + +```python +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. + + +#### `exchange_authorization_code` + +```python +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. + + +#### `load_refresh_token` + +```python +load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None +``` + +Load refresh token from local storage. + + +#### `exchange_refresh_token` + +```python +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) + + +#### `load_access_token` + +```python +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. + + +#### `revoke_token` + +```python +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. + + +#### `get_routes` + +```python +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. + diff --git a/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx index a2fba81e9..f350959c7 100644 --- a/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx @@ -3,402 +3,11 @@ title: oauth_proxy sidebarTitle: oauth_proxy --- -# `fastmcp.server.auth.oauth_dcr_proxy` +# `fastmcp.server.auth.oauth_proxy` -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. - - -## Functions - -### `create_consent_html` - -```python -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. - - -## Classes - -### `OAuthTransaction` - - -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. - - -### `ClientCode` - - -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. - - -### `UpstreamTokenSet` - - -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. - - -### `JTIMapping` - - -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. - - -### `ProxyDCRClient` - - -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. - - -**Methods:** - -#### `validate_redirect_uri` - -```python -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. - - -### `TokenHandler` - - -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." - - -**Methods:** - -#### `response` - -```python -response(self, obj: TokenSuccessResponse | TokenErrorResponse) -``` - -Override response method to provide OAuth 2.1 compliant error handling. - - -### `OAuthProxy` - - -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 - - -**Methods:** - -#### `get_client` - -```python -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). - - -#### `register_client` - -```python -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. - - -#### `authorize` - -```python -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 - - -#### `load_authorization_code` - -```python -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. - - -#### `exchange_authorization_code` - -```python -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. - - -#### `load_refresh_token` - -```python -load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None -``` - -Load refresh token from local storage. - - -#### `exchange_refresh_token` - -```python -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) - - -#### `load_access_token` - -```python -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. - - -#### `revoke_token` - -```python -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. - - -#### `get_routes` - -```python -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. +The OauthProxy class has been moved to fastmcp.server.auth.oauth_dcr_proxy.OAuthDCRProxy +for better organization. This module provides a backwards-compatible import. diff --git a/docs/python-sdk/fastmcp-server-auth-oidc_dcr_proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oidc_dcr_proxy.mdx new file mode 100644 index 000000000..2573e9b29 --- /dev/null +++ b/docs/python-sdk/fastmcp-server-auth-oidc_dcr_proxy.mdx @@ -0,0 +1,82 @@ +--- +title: oidc_dcr_proxy +sidebarTitle: oidc_dcr_proxy +--- + +# `fastmcp.server.auth.oidc_dcr_proxy` + + +OIDC Proxy Provider for FastMCP. + +This provider acts as a transparent proxy to an upstream OIDC compliant Authorization +Server. It leverages the OAuthProxy class to handle Dynamic Client Registration and +forwarding of all OAuth flows. + +This implementation is based on: + OpenID Connect Discovery 1.0 - https://openid.net/specs/openid-connect-discovery-1_0.html + OAuth 2.0 Authorization Server Metadata - https://datatracker.ietf.org/doc/html/rfc8414 + + +## Classes + +### `OIDCConfiguration` + + +OIDC Configuration. + + +**Methods:** + +#### `get_oidc_configuration` + +```python +get_oidc_configuration(cls, config_url: AnyHttpUrl) -> Self +``` + +Get the OIDC configuration for the specified config URL. + +**Args:** +- `config_url`: The OIDC config URL +- `strict`: The strict flag for the configuration +- `timeout_seconds`: HTTP request timeout in seconds + + +### `OIDCDCRProxy` + + +OAuth provider that wraps OAuthDCRProxy to provide configuration via an OIDC configuration URL. + +This provider makes it easier to add OAuth protection for any upstream provider +that is OIDC compliant. + + +**Methods:** + +#### `get_oidc_configuration` + +```python +get_oidc_configuration(self, config_url: AnyHttpUrl, strict: bool | None, timeout_seconds: int | None) -> OIDCConfiguration +``` + +Gets the OIDC configuration for the specified configuration URL. + +**Args:** +- `config_url`: The OIDC configuration URL +- `strict`: The strict flag for the configuration +- `timeout_seconds`: HTTP request timeout in seconds + + +#### `get_token_verifier` + +```python +get_token_verifier(self) -> TokenVerifier +``` + +Creates the token verifier for the specified OIDC configuration and arguments. + +**Args:** +- `algorithm`: Optional token verifier algorithm +- `audience`: Optional token verifier audience +- `required_scopes`: Optional token verifier required_scopes +- `timeout_seconds`: HTTP request timeout in seconds + diff --git a/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx index 39360e222..1ec3188b0 100644 --- a/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx @@ -6,77 +6,8 @@ sidebarTitle: oidc_proxy # `fastmcp.server.auth.oidc_proxy` -OIDC Proxy Provider for FastMCP. +Backwards compatibility shim for oidc_proxy.py -This provider acts as a transparent proxy to an upstream OIDC compliant Authorization -Server. It leverages the OAuthProxy class to handle Dynamic Client Registration and -forwarding of all OAuth flows. - -This implementation is based on: - OpenID Connect Discovery 1.0 - https://openid.net/specs/openid-connect-discovery-1_0.html - OAuth 2.0 Authorization Server Metadata - https://datatracker.ietf.org/doc/html/rfc8414 - - -## Classes - -### `OIDCConfiguration` - - -OIDC Configuration. - - -**Methods:** - -#### `get_oidc_configuration` - -```python -get_oidc_configuration(cls, config_url: AnyHttpUrl) -> Self -``` - -Get the OIDC configuration for the specified config URL. - -**Args:** -- `config_url`: The OIDC config URL -- `strict`: The strict flag for the configuration -- `timeout_seconds`: HTTP request timeout in seconds - - -### `OIDCDCRProxy` - - -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 -that is OIDC compliant. - - -**Methods:** - -#### `get_oidc_configuration` - -```python -get_oidc_configuration(self, config_url: AnyHttpUrl, strict: bool | None, timeout_seconds: int | None) -> OIDCConfiguration -``` - -Gets the OIDC configuration for the specified configuration URL. - -**Args:** -- `config_url`: The OIDC configuration URL -- `strict`: The strict flag for the configuration -- `timeout_seconds`: HTTP request timeout in seconds - - -#### `get_token_verifier` - -```python -get_token_verifier(self) -> TokenVerifier -``` - -Creates the token verifier for the specified OIDC configuration and arguments. - -**Args:** -- `algorithm`: Optional token verifier algorithm -- `audience`: Optional token verifier audience -- `required_scopes`: Optional token verifier required_scopes -- `timeout_seconds`: HTTP request timeout in seconds +The OIDCProxy class has been moved to fastmcp.server.auth.oidc_dcr_proxy.OIDCDCRProxy +for better organization. This module provides a backwards-compatible import. diff --git a/docs/python-sdk/fastmcp-server-auth-providers-auth0.mdx b/docs/python-sdk/fastmcp-server-auth-providers-auth0.mdx index 097662791..60c537318 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-auth0.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-auth0.mdx @@ -14,10 +14,10 @@ just the configuration URL, client ID, client secret, audience, and base URL. Example: ```python from fastmcp import FastMCP - from fastmcp.server.auth.providers.auth0 import Auth0Provider + from fastmcp.server.auth.providers.auth0 import Auth0DCRProvider # Simple Auth0 OAuth protection - auth = Auth0Provider( + auth = Auth0DCRProvider( config_url="https://auth0.config.url", client_id="your-auth0-client-id", client_secret="your-auth0-client-secret", @@ -31,17 +31,33 @@ Example: ## Classes -### `Auth0ProviderSettings` +### `Auth0DCRProviderSettings` -Settings for Auth0 OIDC provider. +Settings for Auth0 OIDC DCR provider. -### `Auth0Provider` +**Methods:** + +#### `settings_customise_sources` + +```python +settings_customise_sources(cls, settings_cls, init_settings, env_settings, dotenv_settings, file_secret_settings) +``` + +### `Auth0DCRProvider` -An Auth0 provider implementation for FastMCP. +An Auth0 DCR provider implementation for FastMCP. This provider is a complete Auth0 integration that's ready to use with just the configuration URL, client ID, client secret, audience, and base URL. + +### `Auth0Provider` + + +Deprecated: Use Auth0DCRProvider instead. + +This alias is provided for backwards compatibility and will be removed in a future version. + diff --git a/docs/python-sdk/fastmcp-server-auth-providers-aws.mdx b/docs/python-sdk/fastmcp-server-auth-providers-aws.mdx index 351732b17..6780a6c7a 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-aws.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-aws.mdx @@ -31,13 +31,21 @@ Example: ## Classes -### `AWSCognitoProviderSettings` +### `AWSCognitoDCRProviderSettings` -Settings for AWS Cognito OAuth provider. +Settings for AWS Cognito OAuth DCR provider. -### `AWSCognitoTokenVerifier` +**Methods:** + +#### `settings_customise_sources` + +```python +settings_customise_sources(cls, settings_cls, init_settings, env_settings, dotenv_settings, file_secret_settings) +``` + +### `AWSCognitoTokenVerifier` Token verifier that filters claims to Cognito-specific subset. @@ -45,7 +53,7 @@ Token verifier that filters claims to Cognito-specific subset. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -54,10 +62,10 @@ verify_token(self, token: str) -> AccessToken | None Verify token and filter claims to Cognito-specific subset. -### `AWSCognitoProvider` +### `AWSCognitoDCRProvider` -Complete AWS Cognito OAuth provider for FastMCP. +Complete AWS Cognito OAuth DCR provider for FastMCP. This provider makes it trivial to add AWS Cognito OAuth protection to any FastMCP server using OIDC Discovery. Just provide your Cognito User Pool details, @@ -70,9 +78,17 @@ Features: - Support for Cognito User Pools +### `AWSCognitoProvider` + + +Deprecated: Use AWSCognitoDCRProvider instead. + +This alias is provided for backwards compatibility and will be removed in a future version. + + **Methods:** -#### `get_token_verifier` +#### `get_token_verifier` ```python get_token_verifier(self) -> TokenVerifier diff --git a/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx b/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx index d9748f0ed..899bd6774 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx @@ -14,16 +14,24 @@ using the OAuth Proxy pattern for non-DCR OAuth flows. ## Classes -### `AzureProviderSettings` +### `AzureDCRProviderSettings` -Settings for Azure OAuth provider. +Settings for Azure OAuth DCR provider. -### `AzureProvider` +**Methods:** + +#### `settings_customise_sources` + +```python +settings_customise_sources(cls, settings_cls, init_settings, env_settings, dotenv_settings, file_secret_settings) +``` + +### `AzureDCRProvider` -Azure (Microsoft Entra) OAuth provider for FastMCP. +Azure (Microsoft Entra) OAuth DCR provider for FastMCP. This provider implements Azure/Microsoft Entra ID authentication using the OAuth Proxy pattern. It supports both organizational accounts and personal @@ -43,9 +51,17 @@ Setup: 6. Get Application (client) ID, Directory (tenant) ID, and client secret +### `AzureProvider` + + +Deprecated: Use AzureDCRProvider instead. + +This alias is provided for backwards compatibility and will be removed in a future version. + + **Methods:** -#### `authorize` +#### `authorize` ```python authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str diff --git a/docs/python-sdk/fastmcp-server-auth-providers-github.mdx b/docs/python-sdk/fastmcp-server-auth-providers-github.mdx index 5358f2817..93be63306 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-github.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-github.mdx @@ -15,10 +15,10 @@ GitHub's OAuth flow, token validation, and user management. Example: ```python from fastmcp import FastMCP - from fastmcp.server.auth.providers.github import GitHubProvider + from fastmcp.server.auth.providers.github import GitHubDCRProvider # Simple GitHub OAuth protection - auth = GitHubProvider( + auth = GitHubDCRProvider( client_id="your-github-client-id", client_secret="your-github-client-secret" ) @@ -29,13 +29,21 @@ Example: ## Classes -### `GitHubProviderSettings` +### `GitHubDCRProviderSettings` -Settings for GitHub OAuth provider. +Settings for GitHub OAuth DCR provider. -### `GitHubTokenVerifier` +**Methods:** + +#### `settings_customise_sources` + +```python +settings_customise_sources(cls, settings_cls, init_settings, env_settings, dotenv_settings, file_secret_settings) +``` + +### `GitHubTokenVerifier` Token verifier for GitHub OAuth tokens. @@ -46,7 +54,7 @@ by calling GitHub's API to check if they're valid and get user info. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -55,10 +63,10 @@ verify_token(self, token: str) -> AccessToken | None Verify GitHub OAuth token by calling GitHub API. -### `GitHubProvider` +### `GitHubDCRProvider` -Complete GitHub OAuth provider for FastMCP. +Complete GitHub OAuth DCR provider for FastMCP. This provider makes it trivial to add GitHub OAuth protection to any FastMCP server. Just provide your GitHub OAuth app credentials and @@ -70,3 +78,11 @@ Features: - User information extraction - Minimal configuration required + +### `GitHubProvider` + + +Deprecated: Use GitHubDCRProvider instead. + +This alias is provided for backwards compatibility and will be removed in a future version. + diff --git a/docs/python-sdk/fastmcp-server-auth-providers-google.mdx b/docs/python-sdk/fastmcp-server-auth-providers-google.mdx index 006c22db3..83d04fb19 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-google.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-google.mdx @@ -15,10 +15,10 @@ Google's OAuth flow, token validation, and user management. Example: ```python from fastmcp import FastMCP - from fastmcp.server.auth.providers.google import GoogleProvider + from fastmcp.server.auth.providers.google import GoogleDCRProvider # Simple Google OAuth protection - auth = GoogleProvider( + auth = GoogleDCRProvider( client_id="your-google-client-id.apps.googleusercontent.com", client_secret="your-google-client-secret" ) @@ -29,13 +29,21 @@ Example: ## Classes -### `GoogleProviderSettings` +### `GoogleDCRProviderSettings` -Settings for Google OAuth provider. +Settings for Google OAuth DCR provider. -### `GoogleTokenVerifier` +**Methods:** + +#### `settings_customise_sources` + +```python +settings_customise_sources(cls, settings_cls, init_settings, env_settings, dotenv_settings, file_secret_settings) +``` + +### `GoogleTokenVerifier` Token verifier for Google OAuth tokens. @@ -46,7 +54,7 @@ by calling Google's tokeninfo API to check if they're valid and get user info. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -55,10 +63,10 @@ verify_token(self, token: str) -> AccessToken | None Verify Google OAuth token by calling Google's tokeninfo API. -### `GoogleProvider` +### `GoogleDCRProvider` -Complete Google OAuth provider for FastMCP. +Complete Google OAuth DCR provider for FastMCP. This provider makes it trivial to add Google OAuth protection to any FastMCP server. Just provide your Google OAuth app credentials and @@ -70,3 +78,11 @@ Features: - User information extraction from Google APIs - Minimal configuration required + +### `GoogleProvider` + + +Deprecated: Use GoogleDCRProvider instead. + +This alias is provided for backwards compatibility and will be removed in a future version. + diff --git a/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx b/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx index 60b8ccb67..bd272d139 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx @@ -10,7 +10,7 @@ WorkOS authentication providers for FastMCP. This module provides two WorkOS authentication strategies: -1. WorkOSProvider - OAuth proxy for WorkOS Connect applications (non-DCR) +1. WorkOSDCRProvider - OAuth DCR proxy for WorkOS Connect applications 2. AuthKitProvider - DCR-compliant provider for WorkOS AuthKit Choose based on your WorkOS setup and authentication requirements. @@ -18,13 +18,21 @@ Choose based on your WorkOS setup and authentication requirements. ## Classes -### `WorkOSProviderSettings` +### `WorkOSDCRProviderSettings` -Settings for WorkOS OAuth provider. +Settings for WorkOS OAuth DCR provider. -### `WorkOSTokenVerifier` +**Methods:** + +#### `settings_customise_sources` + +```python +settings_customise_sources(cls, settings_cls, init_settings, env_settings, dotenv_settings, file_secret_settings) +``` + +### `WorkOSTokenVerifier` Token verifier for WorkOS OAuth tokens. @@ -35,7 +43,7 @@ the /oauth2/userinfo endpoint to check validity and get user info. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -44,16 +52,16 @@ verify_token(self, token: str) -> AccessToken | None Verify WorkOS OAuth token by calling userinfo endpoint. -### `WorkOSProvider` +### `WorkOSDCRProvider` -Complete WorkOS OAuth provider for FastMCP. +Complete WorkOS OAuth DCR provider for FastMCP. -This provider implements WorkOS AuthKit OAuth using the OAuth Proxy pattern. +This provider implements WorkOS AuthKit OAuth using the OAuth DCR Proxy pattern. It provides OAuth2 authentication for users through WorkOS Connect applications. Features: -- Transparent OAuth proxy to WorkOS AuthKit +- Transparent OAuth DCR proxy to WorkOS AuthKit - Automatic token validation via userinfo endpoint - User information extraction from ID tokens - Support for standard OAuth scopes (openid, profile, email) @@ -65,9 +73,17 @@ Setup Requirements: 4. Note your Client ID and Client Secret -### `AuthKitProviderSettings` +### `WorkOSProvider` -### `AuthKitProvider` + +Deprecated: Use WorkOSDCRProvider instead. + +This alias is provided for backwards compatibility and will be removed in a future version. + + +### `AuthKitProviderSettings` + +### `AuthKitProvider` AuthKit metadata provider for DCR (Dynamic Client Registration). @@ -93,7 +109,7 @@ https://workos.com/docs/authkit/mcp/integrating/token-verification **Methods:** -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-http.mdx b/docs/python-sdk/fastmcp-server-http.mdx index d06e214a0..40ae6fb9b 100644 --- a/docs/python-sdk/fastmcp-server-http.mdx +++ b/docs/python-sdk/fastmcp-server-http.mdx @@ -7,13 +7,13 @@ sidebarTitle: http ## Functions -### `set_http_request` +### `set_http_request` ```python set_http_request(request: Request) -> Generator[Request, None, None] ``` -### `create_base_app` +### `create_base_app` ```python create_base_app(routes: list[BaseRoute], middleware: list[Middleware], debug: bool = False, lifespan: Callable | None = None) -> StarletteWithLifespan @@ -32,7 +32,7 @@ Create a base Starlette app with common middleware and routes. - A Starlette application -### `create_sse_app` +### `create_sse_app` ```python create_sse_app(server: FastMCP[LifespanResultT], message_path: str, sse_path: str, auth: AuthProvider | None = None, debug: bool = False, routes: list[BaseRoute] | None = None, middleware: list[Middleware] | None = None) -> StarletteWithLifespan @@ -54,7 +54,7 @@ Returns: A Starlette application with RequestContextMiddleware -### `create_streamable_http_app` +### `create_streamable_http_app` ```python create_streamable_http_app(server: FastMCP[LifespanResultT], streamable_http_path: str, event_store: EventStore | None = None, auth: AuthProvider | None = None, json_response: bool = False, stateless_http: bool = False, debug: bool = False, routes: list[BaseRoute] | None = None, middleware: list[Middleware] | None = None) -> StarletteWithLifespan @@ -80,23 +80,23 @@ Return an instance of the StreamableHTTP server app. ## Classes -### `StreamableHTTPASGIApp` +### `StreamableHTTPASGIApp` ASGI application wrapper for Streamable HTTP server transport. -### `StarletteWithLifespan` +### `StarletteWithLifespan` **Methods:** -#### `lifespan` +#### `lifespan` ```python lifespan(self) -> Lifespan[Starlette] ``` -### `RequestContextMiddleware` +### `RequestContextMiddleware` Middleware that stores each request in a ContextVar From bd2c554e44e7d77f4d18cfd47453766ad22fef65 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 20 Oct 2025 18:07:12 -0400 Subject: [PATCH 09/15] Move authorize method from deprecated AzureProvider to AzureDCRProvider The authorize method with scope prefixing and resource filtering belongs on the main AzureDCRProvider class, not just the deprecated alias. --- src/fastmcp/server/auth/providers/azure.py | 34 +++++++++++----------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index 19cc81495..e6b022a71 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -254,23 +254,6 @@ class AzureDCRProvider(OAuthDCRProxy): f" and identifier_uri {self.identifier_uri}" if self.identifier_uri else "", ) - -# Deprecated alias for backwards compatibility -class AzureProvider(AzureDCRProvider): - """Deprecated: Use AzureDCRProvider instead. - - This alias is provided for backwards compatibility and will be removed in a future version. - """ - - def __init__(self, **kwargs): - if settings_module.settings.deprecation_warnings: - warnings.warn( - "AzureProvider is deprecated, use AzureDCRProvider instead", - DeprecationWarning, - stacklevel=2, - ) - super().__init__(**kwargs) - async def authorize( self, client: OAuthClientInformationFull, @@ -322,3 +305,20 @@ class AzureProvider(AzureDCRProvider): def _add_prefix_to_scopes(self, scopes: list[str]) -> list[str]: """Add Application ID URI prefix for authorization request.""" return [f"{self.identifier_uri}/{scope}" for scope in scopes] + + +# Deprecated alias for backwards compatibility +class AzureProvider(AzureDCRProvider): + """Deprecated: Use AzureDCRProvider instead. + + This alias is provided for backwards compatibility and will be removed in a future version. + """ + + def __init__(self, **kwargs): + if settings_module.settings.deprecation_warnings: + warnings.warn( + "AzureProvider is deprecated, use AzureDCRProvider instead", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(**kwargs) From a9b77e76d00f6625868e46499523f184356442a9 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 20 Oct 2025 18:07:28 -0400 Subject: [PATCH 10/15] Remove outdated oidc_proxy test file This test file was testing the old oidc_proxy module which has been renamed to oidc_dcr_proxy. The module is only used internally by providers and doesn't need backwards compatibility. --- .../auth/oauth_dcr_proxy/test_oidc_proxy.py | 645 ------------------ 1 file changed, 645 deletions(-) delete mode 100644 tests/server/auth/oauth_dcr_proxy/test_oidc_proxy.py diff --git a/tests/server/auth/oauth_dcr_proxy/test_oidc_proxy.py b/tests/server/auth/oauth_dcr_proxy/test_oidc_proxy.py deleted file mode 100644 index 86384af3d..000000000 --- a/tests/server/auth/oauth_dcr_proxy/test_oidc_proxy.py +++ /dev/null @@ -1,645 +0,0 @@ -"""Comprehensive tests for OIDC Proxy Provider functionality.""" - -import json -from unittest.mock import MagicMock, patch - -import pytest -from httpx import Response -from pydantic import AnyHttpUrl - -from fastmcp.server.auth.oidc_dcr_proxy import OIDCConfiguration, OIDCDCRProxy -from fastmcp.server.auth.providers.jwt import JWTVerifier - -TEST_ISSUER = "https://example.com" -TEST_AUTHORIZATION_ENDPOINT = "https://example.com/authorize" -TEST_TOKEN_ENDPOINT = "https://example.com/oauth/token" - -TEST_CONFIG_URL = "https://example.com/.well-known/openid-configuration" -TEST_CLIENT_ID = "test-client-id" -TEST_CLIENT_SECRET = "test-client-secret" -TEST_BASE_URL = "https://example.com:8000/" - - -# ============================================================================= -# Test Fixtures -# ============================================================================= - - -@pytest.fixture -def valid_oidc_configuration_dict(): - """Create a valid OIDC configuration dict for testing.""" - return { - "issuer": TEST_ISSUER, - "authorization_endpoint": TEST_AUTHORIZATION_ENDPOINT, - "token_endpoint": TEST_TOKEN_ENDPOINT, - "jwks_uri": "https://example.com/.well-known/jwks.json", - "response_types_supported": ["code"], - "subject_types_supported": ["public"], - "id_token_signing_alg_values_supported": ["RS256"], - } - - -@pytest.fixture -def invalid_oidc_configuration_dict(): - """Create an invalid OIDC configuration dict for testing.""" - return { - "issuer": TEST_ISSUER, - "authorization_endpoint": TEST_AUTHORIZATION_ENDPOINT, - "token_endpoint": TEST_TOKEN_ENDPOINT, - "jwks_uri": "https://example.com/.well-known/jwks.json", - } - - -@pytest.fixture -def valid_google_oidc_configuration_dict(): - """Create a valid Google OIDC configuration dict for testing. - - See: https://accounts.google.com/.well-known/openid-configuration - """ - google_config_str = """ - { - "issuer": "https://accounts.google.com", - "authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth", - "device_authorization_endpoint": "https://oauth2.googleapis.com/device/code", - "token_endpoint": "https://oauth2.googleapis.com/token", - "userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo", - "revocation_endpoint": "https://oauth2.googleapis.com/revoke", - "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs", - "response_types_supported": [ - "code", - "token", - "id_token", - "code token", - "code id_token", - "token id_token", - "code token id_token", - "none" - ], - "response_modes_supported": [ - "query", - "fragment", - "form_post" - ], - "subject_types_supported": [ - "public" - ], - "id_token_signing_alg_values_supported": [ - "RS256" - ], - "scopes_supported": [ - "openid", - "email", - "profile" - ], - "token_endpoint_auth_methods_supported": [ - "client_secret_post", - "client_secret_basic" - ], - "claims_supported": [ - "aud", - "email", - "email_verified", - "exp", - "family_name", - "given_name", - "iat", - "iss", - "name", - "picture", - "sub" - ], - "code_challenge_methods_supported": [ - "plain", - "S256" - ], - "grant_types_supported": [ - "authorization_code", - "refresh_token", - "urn:ietf:params:oauth:grant-type:device_code", - "urn:ietf:params:oauth:grant-type:jwt-bearer" - ] - } - """ - - return json.loads(google_config_str) - - -@pytest.fixture -def valid_auth0_oidc_configuration_dict(): - """Create a valid Auth0 OIDC configuration dict for testing. - - See: https://.us.auth0.com/.well-known/openid-configuration - """ - auth0_config_str = """ - { - "issuer": "https://example.us.auth0.com/", - "authorization_endpoint": "https://example.us.auth0.com/authorize", - "token_endpoint": "https://example.us.auth0.com/oauth/token", - "device_authorization_endpoint": "https://example.us.auth0.com/oauth/device/code", - "userinfo_endpoint": "https://example.us.auth0.com/userinfo", - "mfa_challenge_endpoint": "https://example.us.auth0.com/mfa/challenge", - "jwks_uri": "https://example.us.auth0.com/.well-known/jwks.json", - "registration_endpoint": "https://example.us.auth0.com/oidc/register", - "revocation_endpoint": "https://example.us.auth0.com/oauth/revoke", - "scopes_supported": [ - "openid", - "profile", - "offline_access", - "name", - "given_name", - "family_name", - "nickname", - "email", - "email_verified", - "picture", - "created_at", - "identities", - "phone", - "address" - ], - "response_types_supported": [ - "code", - "token", - "id_token", - "code token", - "code id_token", - "token id_token", - "code token id_token" - ], - "code_challenge_methods_supported": [ - "S256", - "plain" - ], - "response_modes_supported": [ - "query", - "fragment", - "form_post" - ], - "subject_types_supported": [ - "public" - ], - "token_endpoint_auth_methods_supported": [ - "client_secret_basic", - "client_secret_post", - "private_key_jwt", - "tls_client_auth", - "self_signed_tls_client_auth" - ], - "token_endpoint_auth_signing_alg_values_supported": [ - "RS256", - "RS384", - "PS256" - ], - "claims_supported": [ - "aud", - "auth_time", - "created_at", - "email", - "email_verified", - "exp", - "family_name", - "given_name", - "iat", - "identities", - "iss", - "name", - "nickname", - "phone_number", - "picture", - "sub" - ], - "request_uri_parameter_supported": false, - "request_parameter_supported": true, - "id_token_signing_alg_values_supported": [ - "HS256", - "RS256", - "PS256" - ], - "tls_client_certificate_bound_access_tokens": true, - "request_object_signing_alg_values_supported": [ - "RS256", - "RS384", - "PS256" - ], - "backchannel_logout_supported": true, - "backchannel_logout_session_supported": true, - "end_session_endpoint": "https://example.us.auth0.com/oidc/logout", - "backchannel_authentication_endpoint": "https://example.us.auth0.com/bc-authorize", - "backchannel_token_delivery_modes_supported": [ - "poll" - ], - "global_token_revocation_endpoint": "https://example.us.auth0.com/oauth/global-token-revocation/connection/{connectionName}", - "global_token_revocation_endpoint_auth_methods_supported": [ - "global-token-revocation+jwt" - ] - } - """ - - return json.loads(auth0_config_str) - - -# ============================================================================= -# Test Classes -# ============================================================================= - - -def validate_config(config, source_dict): - """Validate an OIDC configuration against the source dict.""" - for source_key, source_value in source_dict.items(): - config_value = getattr(config, source_key, None) - if not hasattr(config, source_key): - continue - - config_value = getattr(config, source_key, None) - if isinstance(config_value, AnyHttpUrl): - config_value = str(config_value) - - assert config_value == source_value - - -class TestOIDCConfiguration: - """Tests for OIDC configuration.""" - - def test_default_configuration(self, valid_oidc_configuration_dict): - """Test default configuration with valid dict.""" - config = OIDCConfiguration.model_validate(valid_oidc_configuration_dict) - validate_config(config, valid_oidc_configuration_dict) - - def test_default_configuration_with_issuer_trailing_slash( - self, valid_oidc_configuration_dict - ): - """Test default configuration with valid dict and issuer trailing slash.""" - valid_oidc_configuration_dict["issuer"] += "/" - config = OIDCConfiguration.model_validate(valid_oidc_configuration_dict) - validate_config(config, valid_oidc_configuration_dict) - - def test_explicit_strict_configuration(self, valid_oidc_configuration_dict): - """Test default configuration with explicit True strict setting and valid dict.""" - valid_oidc_configuration_dict["strict"] = True - config = OIDCConfiguration.model_validate(valid_oidc_configuration_dict) - validate_config(config, valid_oidc_configuration_dict) - - def test_explicit_strict_configuration_with_issuer_trailing_slash( - self, valid_oidc_configuration_dict - ): - """Test default configuration with explicit True strict setting, valid dict and issuer trailing slash.""" - valid_oidc_configuration_dict["issuer"] += "/" - config = OIDCConfiguration.model_validate(valid_oidc_configuration_dict) - validate_config(config, valid_oidc_configuration_dict) - - def test_default_configuration_raises_error(self, invalid_oidc_configuration_dict): - """Test default configuration with invalid dict.""" - with pytest.raises(ValueError, match="Missing required configuration metadata"): - OIDCConfiguration.model_validate(invalid_oidc_configuration_dict) - - def test_explicit_strict_configuration_raises_error( - self, invalid_oidc_configuration_dict - ): - """Test default configuration with explicit True strict setting and invalid dict.""" - invalid_oidc_configuration_dict["strict"] = True - with pytest.raises(ValueError, match="Missing required configuration metadata"): - OIDCConfiguration.model_validate(invalid_oidc_configuration_dict) - - def test_bad_url_raises_error(self, valid_oidc_configuration_dict): - """Test default configuration with bad URL setting.""" - valid_oidc_configuration_dict["issuer"] = "not-a-URL" - with pytest.raises(ValueError, match="Invalid URL for configuration metadata"): - OIDCConfiguration.model_validate(valid_oidc_configuration_dict) - - def test_explict_strict_with_bad_url_raises_error( - self, valid_oidc_configuration_dict - ): - """Test default configuration with explicit True strict setting and bad URL setting.""" - valid_oidc_configuration_dict["strict"] = True - valid_oidc_configuration_dict["issuer"] = "not-a-URL" - with pytest.raises(ValueError, match="Invalid URL for configuration metadata"): - OIDCConfiguration.model_validate(valid_oidc_configuration_dict) - - def test_not_strict_configuration(self): - """Test default configuration with explicit False strict setting.""" - config = OIDCConfiguration.model_validate({"strict": False}) - - assert config.issuer is None - assert config.authorization_endpoint is None - assert config.token_endpoint is None - assert config.jwks_uri is None - assert config.response_types_supported is None - assert config.subject_types_supported is None - assert config.id_token_signing_alg_values_supported is None - - def test_not_strict_configuration_with_invalid_config( - self, invalid_oidc_configuration_dict - ): - """Test default configuration with explicit False strict setting.""" - invalid_oidc_configuration_dict["strict"] = False - config = OIDCConfiguration.model_validate(invalid_oidc_configuration_dict) - - validate_config(config, invalid_oidc_configuration_dict) - - def test_not_strict_configuration_with_bad_url(self, valid_oidc_configuration_dict): - """Test default configuration with explicit False strict setting.""" - valid_oidc_configuration_dict["strict"] = False - valid_oidc_configuration_dict["issuer"] = "not-a-url" - config = OIDCConfiguration.model_validate(valid_oidc_configuration_dict) - - validate_config(config, valid_oidc_configuration_dict) - - def test_google_configuration(self, valid_google_oidc_configuration_dict): - """Test Google configuration.""" - config = OIDCConfiguration.model_validate(valid_google_oidc_configuration_dict) - - validate_config(config, valid_google_oidc_configuration_dict) - - def test_auth0_configuration(self, valid_auth0_oidc_configuration_dict): - """Test Auth0 configuration.""" - config = OIDCConfiguration.model_validate(valid_auth0_oidc_configuration_dict) - - validate_config(config, valid_auth0_oidc_configuration_dict) - - -def validate_get_oidc_configuration(oidc_configuration, strict, timeout_seconds): - """Validate get_oidc_configuation call.""" - with patch("httpx.get") as mock_get: - mock_response = MagicMock(spec=Response) - mock_response.json.return_value = oidc_configuration - mock_get.return_value = mock_response - - config = OIDCConfiguration.get_oidc_configuration( - config_url=AnyHttpUrl(TEST_CONFIG_URL), - strict=strict, - timeout_seconds=timeout_seconds, - ) - - validate_config(config, oidc_configuration) - - mock_get.assert_called_once() - - call_args = mock_get.call_args - assert call_args[0][0] == TEST_CONFIG_URL - - return call_args - - -class TestGetOIDCConfiguration: - """Tests for getting OIDC configuration.""" - - def test_get_oidc_configuration(self, valid_oidc_configuration_dict): - """Test with valid response and explicit timeout.""" - call_args = validate_get_oidc_configuration( - valid_oidc_configuration_dict, True, 10 - ) - assert call_args[1]["timeout"] == 10 - - def test_get_oidc_configuration_no_timeout(self, valid_oidc_configuration_dict): - """Test with valid response and no timeout.""" - call_args = validate_get_oidc_configuration( - valid_oidc_configuration_dict, True, None - ) - assert "timeout" not in call_args[1] - - def test_get_oidc_configuration_raises_error( - self, invalid_oidc_configuration_dict - ) -> None: - """Test with invalid response.""" - with pytest.raises(ValueError, match="Missing required configuration metadata"): - validate_get_oidc_configuration(invalid_oidc_configuration_dict, True, 10) - - def test_get_oidc_configuration_not_strict( - self, invalid_oidc_configuration_dict - ) -> None: - """Test with invalid response and strict set to False.""" - with patch("httpx.get") as mock_get: - mock_response = MagicMock(spec=Response) - mock_response.json.return_value = invalid_oidc_configuration_dict - mock_get.return_value = mock_response - - OIDCConfiguration.get_oidc_configuration( - config_url=AnyHttpUrl(TEST_CONFIG_URL), - strict=False, - timeout_seconds=10, - ) - - mock_get.assert_called_once() - - call_args = mock_get.call_args - assert call_args[0][0] == TEST_CONFIG_URL - - -def validate_proxy(mock_get, proxy, oidc_config): - """Validate OIDC proxy.""" - mock_get.assert_called_once() - - call_args = mock_get.call_args - assert str(call_args[0][0]) == TEST_CONFIG_URL - - assert proxy._upstream_authorization_endpoint == TEST_AUTHORIZATION_ENDPOINT - assert proxy._upstream_token_endpoint == TEST_TOKEN_ENDPOINT - assert proxy._upstream_client_id == TEST_CLIENT_ID - assert proxy._upstream_client_secret.get_secret_value() == TEST_CLIENT_SECRET - assert str(proxy.base_url) == TEST_BASE_URL - assert proxy.oidc_config == oidc_config - - -class TestOIDCDCRProxyInitialization: - """Tests for OIDC proxy initialization.""" - - def test_default_initialization(self, valid_oidc_configuration_dict): - """Test default initialization.""" - with patch( - "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" - ) as mock_get: - oidc_config = OIDCConfiguration.model_validate( - valid_oidc_configuration_dict - ) - mock_get.return_value = oidc_config - - proxy = OIDCDCRProxy( - config_url=TEST_CONFIG_URL, - client_id=TEST_CLIENT_ID, - client_secret=TEST_CLIENT_SECRET, - base_url=TEST_BASE_URL, - ) - - validate_proxy(mock_get, proxy, oidc_config) - - def test_timeout_seconds_initialization(self, valid_oidc_configuration_dict): - """Test timeout seconds initialization.""" - with patch( - "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" - ) as mock_get: - oidc_config = OIDCConfiguration.model_validate( - valid_oidc_configuration_dict - ) - mock_get.return_value = oidc_config - - proxy = OIDCDCRProxy( - config_url=TEST_CONFIG_URL, - client_id=TEST_CLIENT_ID, - client_secret=TEST_CLIENT_SECRET, - base_url=TEST_BASE_URL, - timeout_seconds=12, - ) - - validate_proxy(mock_get, proxy, oidc_config) - - call_args = mock_get.call_args - assert call_args[1]["timeout_seconds"] == 12 - - def test_token_verifier_initialization(self, valid_oidc_configuration_dict): - """Test token verifier initialization.""" - with patch( - "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" - ) as mock_get: - oidc_config = OIDCConfiguration.model_validate( - valid_oidc_configuration_dict - ) - mock_get.return_value = oidc_config - - proxy = OIDCDCRProxy( - config_url=TEST_CONFIG_URL, - client_id=TEST_CLIENT_ID, - client_secret=TEST_CLIENT_SECRET, - base_url=TEST_BASE_URL, - algorithm="RS256", - audience="oidc-proxy-test-audience", - required_scopes=["required", "scopes"], - ) - - validate_proxy(mock_get, proxy, oidc_config) - - assert isinstance(proxy._token_validator, JWTVerifier) - - assert proxy._token_validator.algorithm == "RS256" - assert proxy._token_validator.audience == "oidc-proxy-test-audience" - assert proxy._token_validator.required_scopes == ["required", "scopes"] - - def test_extra_parameters_initialization(self, valid_oidc_configuration_dict): - """Test other parameters initialization.""" - with patch( - "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" - ) as mock_get: - oidc_config = OIDCConfiguration.model_validate( - valid_oidc_configuration_dict - ) - mock_get.return_value = oidc_config - - proxy = OIDCDCRProxy( - config_url=TEST_CONFIG_URL, - client_id=TEST_CLIENT_ID, - client_secret=TEST_CLIENT_SECRET, - base_url=TEST_BASE_URL, - audience="oidc-proxy-test-audience", - ) - - validate_proxy(mock_get, proxy, oidc_config) - - assert proxy._extra_authorize_params == { - "audience": "oidc-proxy-test-audience" - } - assert proxy._extra_token_params == {"audience": "oidc-proxy-test-audience"} - - def test_other_parameters_initialization(self, valid_oidc_configuration_dict): - """Test other parameters initialization.""" - with patch( - "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" - ) as mock_get: - oidc_config = OIDCConfiguration.model_validate( - valid_oidc_configuration_dict - ) - mock_get.return_value = oidc_config - - proxy = OIDCDCRProxy( - config_url=TEST_CONFIG_URL, - client_id=TEST_CLIENT_ID, - client_secret=TEST_CLIENT_SECRET, - base_url=TEST_BASE_URL, - redirect_path="/oidc/proxy", - allowed_client_redirect_uris=["http://localhost:*"], - token_endpoint_auth_method="client_secret_post", - ) - - validate_proxy(mock_get, proxy, oidc_config) - - assert proxy._redirect_path == "/oidc/proxy" - assert proxy._allowed_client_redirect_uris == ["http://localhost:*"] - assert proxy._token_endpoint_auth_method == "client_secret_post" - - def test_no_config_url_initialization_raises_error( - self, valid_oidc_configuration_dict - ): - """Test no config URL initialization.""" - with patch( - "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" - ) as mock_get: - oidc_config = OIDCConfiguration.model_validate( - valid_oidc_configuration_dict - ) - mock_get.return_value = oidc_config - - with pytest.raises(ValueError, match="Missing required config URL"): - OIDCDCRProxy( - config_url=None, # type: ignore - client_id=TEST_CLIENT_ID, - client_secret=TEST_CLIENT_SECRET, - base_url=TEST_BASE_URL, - ) - - def test_no_client_id_initialization_raises_error( - self, valid_oidc_configuration_dict - ): - """Test no client id initialization.""" - with patch( - "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" - ) as mock_get: - oidc_config = OIDCConfiguration.model_validate( - valid_oidc_configuration_dict - ) - mock_get.return_value = oidc_config - - with pytest.raises(ValueError, match="Missing required client id"): - OIDCDCRProxy( - config_url=TEST_CONFIG_URL, - client_id=None, # type: ignore - client_secret=TEST_CLIENT_SECRET, - base_url=TEST_BASE_URL, - ) - - def test_no_client_secret_initialization_raises_error( - self, valid_oidc_configuration_dict - ): - """Test no client secret initialization.""" - with patch( - "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" - ) as mock_get: - oidc_config = OIDCConfiguration.model_validate( - valid_oidc_configuration_dict - ) - mock_get.return_value = oidc_config - - with pytest.raises(ValueError, match="Missing required client secret"): - OIDCDCRProxy( - config_url=TEST_CONFIG_URL, - client_id=TEST_CLIENT_ID, - client_secret=None, # type: ignore - base_url=TEST_BASE_URL, - ) - - def test_no_base_url_initialization_raises_error( - self, valid_oidc_configuration_dict - ): - """Test no base URL initialization.""" - with patch( - "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" - ) as mock_get: - oidc_config = OIDCConfiguration.model_validate( - valid_oidc_configuration_dict - ) - mock_get.return_value = oidc_config - - with pytest.raises(ValueError, match="Missing required base URL"): - OIDCDCRProxy( - config_url=TEST_CONFIG_URL, - client_id=TEST_CLIENT_ID, - client_secret=TEST_CLIENT_SECRET, - base_url=None, # type: ignore - ) From 17b60c8815de12bc47cbe484f6d7f08a8d3f1ee9 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 20 Oct 2025 19:12:56 -0400 Subject: [PATCH 11/15] Fix settings import pattern in OAuth providers Use direct imports for non-deprecated items (ENV_FILE, ExtendedEnvSettingsSource, ExtendedSettingsConfigDict) and settings_module for the deprecated settings instance. --- examples/auth/github_oauth/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/auth/github_oauth/server.py b/examples/auth/github_oauth/server.py index 84b00d349..3b01d470f 100644 --- a/examples/auth/github_oauth/server.py +++ b/examples/auth/github_oauth/server.py @@ -13,7 +13,7 @@ To run: import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.github import GitHubDCRProvider +from fastmcp.server.auth.providers.github import GitHubProvider as GitHubDCRProvider auth = GitHubDCRProvider( client_id=os.getenv("FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID") or "", From 598b10090ee9e28d71a197bebe6cd98c603292b1 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 20 Oct 2025 19:14:44 -0400 Subject: [PATCH 12/15] Use full path for settings.deprecation_warnings access Changed from settings_module.settings.deprecation_warnings to fastmcp.settings.settings.deprecation_warnings for clarity. --- src/fastmcp/server/auth/providers/auth0.py | 4 ++-- src/fastmcp/server/auth/providers/aws.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 ++-- src/fastmcp/settings.py | 1 + 7 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/fastmcp/server/auth/providers/auth0.py b/src/fastmcp/server/auth/providers/auth0.py index b726d0106..1b8e7fb37 100644 --- a/src/fastmcp/server/auth/providers/auth0.py +++ b/src/fastmcp/server/auth/providers/auth0.py @@ -27,7 +27,7 @@ from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator from pydantic_settings import BaseSettings -import fastmcp.settings as settings_module +import fastmcp.settings from fastmcp.server.auth.oidc_dcr_proxy import OIDCDCRProxy from fastmcp.settings import ( ENV_FILE, @@ -212,7 +212,7 @@ class Auth0Provider(Auth0DCRProvider): """ def __init__(self, **kwargs): - if settings_module.settings.deprecation_warnings: + if fastmcp.settings.settings.deprecation_warnings: warnings.warn( "Auth0Provider is deprecated, use Auth0DCRProvider instead", DeprecationWarning, diff --git a/src/fastmcp/server/auth/providers/aws.py b/src/fastmcp/server/auth/providers/aws.py index 3ae92f7f4..d5f4ed576 100644 --- a/src/fastmcp/server/auth/providers/aws.py +++ b/src/fastmcp/server/auth/providers/aws.py @@ -29,7 +29,7 @@ from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator from pydantic_settings import BaseSettings -import fastmcp.settings as settings_module +import fastmcp.settings from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.oidc_dcr_proxy import OIDCDCRProxy @@ -263,7 +263,7 @@ class AWSCognitoProvider(AWSCognitoDCRProvider): """ def __init__(self, **kwargs): - if settings_module.settings.deprecation_warnings: + if fastmcp.settings.settings.deprecation_warnings: warnings.warn( "AWSCognitoProvider is deprecated, use AWSCognitoDCRProvider instead", DeprecationWarning, diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index e6b022a71..9fb0f51a8 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -13,7 +13,7 @@ from key_value.aio.protocols import AsyncKeyValue from pydantic import SecretStr, field_validator from pydantic_settings import BaseSettings -import fastmcp.settings as settings_module +import fastmcp.settings from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy from fastmcp.server.auth.providers.jwt import JWTVerifier from fastmcp.settings import ( @@ -315,7 +315,7 @@ class AzureProvider(AzureDCRProvider): """ def __init__(self, **kwargs): - if settings_module.settings.deprecation_warnings: + if fastmcp.settings.settings.deprecation_warnings: warnings.warn( "AzureProvider is deprecated, use AzureDCRProvider instead", DeprecationWarning, diff --git a/src/fastmcp/server/auth/providers/github.py b/src/fastmcp/server/auth/providers/github.py index 494b6fbcf..7edee4046 100644 --- a/src/fastmcp/server/auth/providers/github.py +++ b/src/fastmcp/server/auth/providers/github.py @@ -28,7 +28,7 @@ from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator from pydantic_settings import BaseSettings -import fastmcp.settings as settings_module +import fastmcp.settings from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy @@ -324,7 +324,7 @@ class GitHubProvider(GitHubDCRProvider): """ def __init__(self, **kwargs): - if settings_module.settings.deprecation_warnings: + if fastmcp.settings.settings.deprecation_warnings: warnings.warn( "GitHubProvider is deprecated, use GitHubDCRProvider instead", DeprecationWarning, diff --git a/src/fastmcp/server/auth/providers/google.py b/src/fastmcp/server/auth/providers/google.py index dbbce95f8..503f4d97d 100644 --- a/src/fastmcp/server/auth/providers/google.py +++ b/src/fastmcp/server/auth/providers/google.py @@ -29,7 +29,7 @@ from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator from pydantic_settings import BaseSettings -import fastmcp.settings as settings_module +import fastmcp.settings from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy @@ -343,7 +343,7 @@ class GoogleProvider(GoogleDCRProvider): """ def __init__(self, **kwargs): - if settings_module.settings.deprecation_warnings: + if fastmcp.settings.settings.deprecation_warnings: warnings.warn( "GoogleProvider is deprecated, use GoogleDCRProvider instead", DeprecationWarning, diff --git a/src/fastmcp/server/auth/providers/workos.py b/src/fastmcp/server/auth/providers/workos.py index 13c1f60ee..ce81209ad 100644 --- a/src/fastmcp/server/auth/providers/workos.py +++ b/src/fastmcp/server/auth/providers/workos.py @@ -19,7 +19,7 @@ from pydantic_settings import BaseSettings from starlette.responses import JSONResponse from starlette.routing import Route -import fastmcp.settings as settings_module +import fastmcp.settings from fastmcp.server.auth import AccessToken, RemoteAuthProvider, TokenVerifier from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy from fastmcp.server.auth.providers.jwt import JWTVerifier @@ -301,7 +301,7 @@ class WorkOSProvider(WorkOSDCRProvider): """ def __init__(self, **kwargs): - if settings_module.settings.deprecation_warnings: + if fastmcp.settings.settings.deprecation_warnings: warnings.warn( "WorkOSProvider is deprecated, use WorkOSDCRProvider instead", DeprecationWarning, diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index ae4b5e617..2bbf95bc5 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -145,6 +145,7 @@ class Settings(BaseSettings): which accessed fastmcp.settings.settings """ # Deprecated in 2.8.0 + breakpoint() logger.warning( "Using fastmcp.settings.settings is deprecated. Use fastmcp.settings instead.", ) From 7118cc5ad9b9fe227c435c02a8466de88a43e1d5 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 20 Oct 2025 19:17:07 -0400 Subject: [PATCH 13/15] Fix deprecated access --- src/fastmcp/server/auth/providers/auth0.py | 2 +- src/fastmcp/server/auth/providers/aws.py | 2 +- src/fastmcp/server/auth/providers/azure.py | 2 +- src/fastmcp/server/auth/providers/github.py | 2 +- src/fastmcp/server/auth/providers/google.py | 2 +- src/fastmcp/server/auth/providers/workos.py | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/fastmcp/server/auth/providers/auth0.py b/src/fastmcp/server/auth/providers/auth0.py index 1b8e7fb37..72693fbd4 100644 --- a/src/fastmcp/server/auth/providers/auth0.py +++ b/src/fastmcp/server/auth/providers/auth0.py @@ -212,7 +212,7 @@ class Auth0Provider(Auth0DCRProvider): """ def __init__(self, **kwargs): - if fastmcp.settings.settings.deprecation_warnings: + if fastmcp.settings.deprecation_warnings: warnings.warn( "Auth0Provider is deprecated, use Auth0DCRProvider instead", DeprecationWarning, diff --git a/src/fastmcp/server/auth/providers/aws.py b/src/fastmcp/server/auth/providers/aws.py index d5f4ed576..6777b0578 100644 --- a/src/fastmcp/server/auth/providers/aws.py +++ b/src/fastmcp/server/auth/providers/aws.py @@ -263,7 +263,7 @@ class AWSCognitoProvider(AWSCognitoDCRProvider): """ def __init__(self, **kwargs): - if fastmcp.settings.settings.deprecation_warnings: + if fastmcp.settings.deprecation_warnings: warnings.warn( "AWSCognitoProvider is deprecated, use AWSCognitoDCRProvider instead", DeprecationWarning, diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index 9fb0f51a8..cb008201a 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -315,7 +315,7 @@ class AzureProvider(AzureDCRProvider): """ def __init__(self, **kwargs): - if fastmcp.settings.settings.deprecation_warnings: + if fastmcp.settings.deprecation_warnings: warnings.warn( "AzureProvider is deprecated, use AzureDCRProvider instead", DeprecationWarning, diff --git a/src/fastmcp/server/auth/providers/github.py b/src/fastmcp/server/auth/providers/github.py index 7edee4046..3805e04df 100644 --- a/src/fastmcp/server/auth/providers/github.py +++ b/src/fastmcp/server/auth/providers/github.py @@ -324,7 +324,7 @@ class GitHubProvider(GitHubDCRProvider): """ def __init__(self, **kwargs): - if fastmcp.settings.settings.deprecation_warnings: + if fastmcp.settings.deprecation_warnings: warnings.warn( "GitHubProvider is deprecated, use GitHubDCRProvider instead", DeprecationWarning, diff --git a/src/fastmcp/server/auth/providers/google.py b/src/fastmcp/server/auth/providers/google.py index 503f4d97d..c916dfeef 100644 --- a/src/fastmcp/server/auth/providers/google.py +++ b/src/fastmcp/server/auth/providers/google.py @@ -343,7 +343,7 @@ class GoogleProvider(GoogleDCRProvider): """ def __init__(self, **kwargs): - if fastmcp.settings.settings.deprecation_warnings: + if fastmcp.settings.deprecation_warnings: warnings.warn( "GoogleProvider is deprecated, use GoogleDCRProvider instead", DeprecationWarning, diff --git a/src/fastmcp/server/auth/providers/workos.py b/src/fastmcp/server/auth/providers/workos.py index ce81209ad..5495d233d 100644 --- a/src/fastmcp/server/auth/providers/workos.py +++ b/src/fastmcp/server/auth/providers/workos.py @@ -301,7 +301,7 @@ class WorkOSProvider(WorkOSDCRProvider): """ def __init__(self, **kwargs): - if fastmcp.settings.settings.deprecation_warnings: + if fastmcp.settings.deprecation_warnings: warnings.warn( "WorkOSProvider is deprecated, use WorkOSDCRProvider instead", DeprecationWarning, From 68c061d565ded717936ce9820d29f70693c6c6f1 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 20 Oct 2025 19:20:01 -0400 Subject: [PATCH 14/15] Remove breakpoint from settings.py --- src/fastmcp/settings.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index 2bbf95bc5..ae4b5e617 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -145,7 +145,6 @@ class Settings(BaseSettings): which accessed fastmcp.settings.settings """ # Deprecated in 2.8.0 - breakpoint() logger.warning( "Using fastmcp.settings.settings is deprecated. Use fastmcp.settings instead.", ) From e55cc531adfc2aa9fb9752d9c0927e263dab2582 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 20 Oct 2025 19:25:07 -0400 Subject: [PATCH 15/15] Apply PR #2156 logging changes to oauth_dcr_proxy.py - Remove info/warning logs for allowed_client_redirect_uris - Add 'and use persistent storage' to production guidance for JWT signing key and token encryption key --- src/fastmcp/server/auth/oauth_dcr_proxy.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/fastmcp/server/auth/oauth_dcr_proxy.py b/src/fastmcp/server/auth/oauth_dcr_proxy.py index 7ce3b3e50..39de76e12 100644 --- a/src/fastmcp/server/auth/oauth_dcr_proxy.py +++ b/src/fastmcp/server/auth/oauth_dcr_proxy.py @@ -607,20 +607,11 @@ class OAuthDCRProxy(OAuthProvider): ) # 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 @@ -776,7 +767,7 @@ class OAuthDCRProxy(OAuthProvider): ) logger.info( "Using ephemeral JWT signing key - tokens will NOT survive server restart. " - "For production, provide explicit jwt_signing_key parameter." + "For production, provide explicit jwt_signing_key parameter and use persistent storage." ) # Initialize JWT issuer @@ -809,7 +800,7 @@ class OAuthDCRProxy(OAuthProvider): 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." + "For production, provide explicit token_encryption_key parameter and use persistent storage." ) self._token_encryption = TokenEncryption(encryption_key)