{client_display} is requesting access to {server_display}.
+Review the details below before approving.
+{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,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,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,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,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( + "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( + "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( + "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( + "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( + "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( + "Invalid action
", status_code=400 + ) diff --git a/src/fastmcp/server/auth/oauth_proxy.py b/src/fastmcp/server/auth/oauth_proxy.py index 717b6aa01..197c7ef0f 100644 --- a/src/fastmcp/server/auth/oauth_proxy.py +++ b/src/fastmcp/server/auth/oauth_proxy.py @@ -1,2013 +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.
-{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,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,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,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,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( - "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( - "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( - "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( - "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( - "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( - "Invalid action
", status_code=400 - ) 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 6d5c82fe8..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_proxy import OAuthProxy -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(OAuthProxy): - """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..72693fbd4 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_proxy import OIDCProxy -from fastmcp.settings import ENV_FILE +import fastmcp.settings +from fastmcp.server.auth.oidc_dcr_proxy import OIDCDCRProxy +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,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(OIDCProxy): - """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(OIDCProxy): 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(OIDCProxy): 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(OIDCProxy): } ) - 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 fastmcp.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 31de6c9a0..6777b0578 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 +import fastmcp.settings 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.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 @@ -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(OIDCProxy): - """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(OIDCProxy): 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(OIDCProxy): 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(OIDCProxy): ) # 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 fastmcp.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 93ad94182..cb008201a 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_proxy import OAuthProxy +import fastmcp.settings +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, +) 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(OAuthProxy): - """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(OAuthProxy): 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(OAuthProxy): 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(OAuthProxy): ) # 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(OAuthProxy): 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,20 +236,20 @@ class AzureProvider(OAuthProxy): 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 "", ) @@ -276,3 +305,20 @@ class AzureProvider(OAuthProxy): 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 fastmcp.settings.deprecation_warnings: + warnings.warn( + "AzureProvider is deprecated, use AzureDCRProvider instead", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(**kwargs) diff --git a/src/fastmcp/server/auth/providers/github.py b/src/fastmcp/server/auth/providers/github.py index d34bf041d..3805e04df 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 +import fastmcp.settings from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken -from fastmcp.server.auth.oauth_proxy import OAuthProxy -from fastmcp.settings import ENV_FILE +from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy +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 @@ -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(OAuthProxy): - """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(OAuthProxy): 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(OAuthProxy): 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(OAuthProxy): ) # 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(OAuthProxy): # 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 fastmcp.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 1d925a3d0..c916dfeef 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 +import fastmcp.settings from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken -from fastmcp.server.auth.oauth_proxy import OAuthProxy -from fastmcp.settings import ENV_FILE +from fastmcp.server.auth.oauth_dcr_proxy import OAuthDCRProxy +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 @@ -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(OAuthProxy): - """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(OAuthProxy): 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(OAuthProxy): 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(OAuthProxy): ) # 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(OAuthProxy): # 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 fastmcp.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 52b6c62d2..5495d233d 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 +import fastmcp.settings 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.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 @@ -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(OAuthProxy): - """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(OAuthProxy): 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(OAuthProxy): 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(OAuthProxy): ) # 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(OAuthProxy): 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 fastmcp.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..ccb059f44 --- /dev/null +++ b/tests/deprecated/test_oauth_dcr_providers.py @@ -0,0 +1,64 @@ +"""Test that deprecated provider imports still work. + +This test file verifies that the old provider class names (without DCR suffix) +can still be imported, are subclasses of the new DCR providers, and emit the +correct deprecation warnings when instantiated. +""" + + +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/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/test_oauth_proxy.py b/tests/server/auth/oauth_dcr_proxy/test_oauth_proxy.py similarity index 97% rename from tests/server/auth/test_oauth_proxy.py rename to tests/server/auth/oauth_dcr_proxy/test_oauth_proxy.py index 59cb0a38a..89b4ac63b 100644 --- a/tests/server/auth/test_oauth_proxy.py +++ b/tests/server/auth/oauth_dcr_proxy/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()) 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/providers/test_auth0.py b/tests/server/auth/providers/test_auth0.py index 01f60e54b..a40764fa0 100644 --- a/tests/server/auth/providers/test_auth0.py +++ b/tests/server/auth/providers/test_auth0.py @@ -5,8 +5,11 @@ from unittest.mock import patch import pytest -from fastmcp.server.auth.oidc_proxy import OIDCConfiguration -from fastmcp.server.auth.providers.auth0 import Auth0Provider, Auth0ProviderSettings +from fastmcp.server.auth.oidc_dcr_proxy import OIDCConfiguration +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", diff --git a/tests/server/auth/test_oidc_proxy.py b/tests/server/auth/test_oidc_proxy.py deleted file mode 100644 index f45f835be..000000000 --- a/tests/server/auth/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_proxy import OIDCConfiguration, OIDCProxy -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://