From c99e0c63519eb5a4e74a93b7a2670403dcc234cf Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 19 Jan 2026 10:10:07 -0500 Subject: [PATCH] Refactor OAuthProxy into focused modules (#2935) --- loq.toml | 4 +- .../server/auth/oauth_proxy/__init__.py | 14 + .../server/auth/oauth_proxy/consent.py | 361 ++++++++ src/fastmcp/server/auth/oauth_proxy/models.py | 178 ++++ .../{oauth_proxy.py => oauth_proxy/proxy.py} | 790 +----------------- src/fastmcp/server/auth/oauth_proxy/ui.py | 277 ++++++ .../auth/test_github_provider_integration.py | 17 +- tests/server/auth/test_oauth_consent_flow.py | 42 +- tests/server/auth/test_oauth_proxy.py | 90 +- .../test_oauth_proxy_redirect_validation.py | 3 +- 10 files changed, 890 insertions(+), 886 deletions(-) create mode 100644 src/fastmcp/server/auth/oauth_proxy/__init__.py create mode 100644 src/fastmcp/server/auth/oauth_proxy/consent.py create mode 100644 src/fastmcp/server/auth/oauth_proxy/models.py rename src/fastmcp/server/auth/{oauth_proxy.py => oauth_proxy/proxy.py} (69%) create mode 100644 src/fastmcp/server/auth/oauth_proxy/ui.py diff --git a/loq.toml b/loq.toml index e3084de73..4c6d7e28b 100644 --- a/loq.toml +++ b/loq.toml @@ -75,8 +75,8 @@ path = "tests/utilities/test_json_schema_type.py" max_lines = 1584 [[rules]] -path = "src/fastmcp/server/auth/oauth_proxy.py" -max_lines = 2282 +path = "src/fastmcp/server/auth/oauth_proxy/proxy.py" +max_lines = 1600 [[rules]] path = "tests/server/test_dependencies.py" diff --git a/src/fastmcp/server/auth/oauth_proxy/__init__.py b/src/fastmcp/server/auth/oauth_proxy/__init__.py new file mode 100644 index 000000000..5e9ff4315 --- /dev/null +++ b/src/fastmcp/server/auth/oauth_proxy/__init__.py @@ -0,0 +1,14 @@ +"""OAuth Proxy Provider for FastMCP. + +This package provides OAuth proxy functionality split across multiple modules: +- models: Pydantic models and constants +- ui: HTML generation functions +- consent: Consent management mixin +- proxy: Main OAuthProxy class +""" + +from fastmcp.server.auth.oauth_proxy.proxy import OAuthProxy + +__all__ = [ + "OAuthProxy", +] diff --git a/src/fastmcp/server/auth/oauth_proxy/consent.py b/src/fastmcp/server/auth/oauth_proxy/consent.py new file mode 100644 index 000000000..6f47a5da7 --- /dev/null +++ b/src/fastmcp/server/auth/oauth_proxy/consent.py @@ -0,0 +1,361 @@ +"""OAuth Proxy Consent Management. + +This module contains consent management functionality for the OAuth proxy. +The ConsentMixin class provides methods for handling user consent flows, +cookie management, and consent page rendering. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import secrets +import time +from base64 import urlsafe_b64encode +from typing import TYPE_CHECKING, Any +from urllib.parse import urlencode, urlparse + +from pydantic import AnyUrl +from starlette.requests import Request +from starlette.responses import HTMLResponse, RedirectResponse + +from fastmcp.server.auth.oauth_proxy.ui import create_consent_html +from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.ui import create_secure_html_response + +if TYPE_CHECKING: + from fastmcp.server.auth.oauth_proxy.proxy import OAuthProxy + +logger = get_logger(__name__) + + +class ConsentMixin: + """Mixin class providing consent management functionality for OAuthProxy. + + This mixin contains all methods related to: + - Cookie signing and verification + - Consent page rendering + - Consent approval/denial handling + - URI normalization for consent tracking + """ + + 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: OAuthProxy, 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: OAuthProxy, 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: OAuthProxy, 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: OAuthProxy, 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: OAuthProxy, 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: OAuthProxy, + 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: OAuthProxy, 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 _handle_consent( + self: OAuthProxy, request: Request + ) -> HTMLResponse | RedirectResponse: + """Handle consent page - dispatch to GET or POST handler based on method.""" + if request.method == "POST": + return await self._submit_consent(request) + return await self._show_consent_page(request) + + async def _show_consent_page( + self: OAuthProxy, 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, + csp_policy=self._consent_csp_policy, + ) + 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: OAuthProxy, 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/models.py b/src/fastmcp/server/auth/oauth_proxy/models.py new file mode 100644 index 000000000..fe6c77941 --- /dev/null +++ b/src/fastmcp/server/auth/oauth_proxy/models.py @@ -0,0 +1,178 @@ +"""OAuth Proxy Models and Constants. + +This module contains all Pydantic models and constants used by the OAuth proxy. +""" + +from __future__ import annotations + +import hashlib +from typing import Any, Final + +from mcp.shared.auth import OAuthClientInformationFull +from pydantic import AnyUrl, BaseModel, Field + +from fastmcp.server.auth.redirect_validation import validate_redirect_uri + +# ------------------------------------------------------------------------- +# Constants +# ------------------------------------------------------------------------- + +# Default token expiration times +DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS: Final[int] = 60 * 60 # 1 hour +DEFAULT_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS: Final[int] = ( + 60 * 60 * 24 * 365 +) # 1 year +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 stored in plaintext within this model. Encryption is handled transparently + at the storage layer via FernetEncryptionWrapper. Tokens are never exposed to MCP clients. + """ + + upstream_token_id: str # Unique ID for this token set + access_token: str # Upstream access token + refresh_token: str | None # 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 RefreshTokenMetadata(BaseModel): + """Metadata for a refresh token, stored keyed by token hash. + + We store only metadata (not the token itself) for security - if storage + is compromised, attackers get hashes they can't reverse into usable tokens. + """ + + client_id: str + scopes: list[str] + expires_at: int | None = None + created_at: float + + +def _hash_token(token: str) -> str: + """Hash a token for secure storage lookup. + + Uses SHA-256 to create a one-way hash. The original token cannot be + recovered from the hash, providing defense in depth if storage is compromised. + """ + return hashlib.sha256(token.encode()).hexdigest() + + +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) diff --git a/src/fastmcp/server/auth/oauth_proxy.py b/src/fastmcp/server/auth/oauth_proxy/proxy.py similarity index 69% rename from src/fastmcp/server/auth/oauth_proxy.py rename to src/fastmcp/server/auth/oauth_proxy/proxy.py index 14c80918a..f81f0884d 100644 --- a/src/fastmcp/server/auth/oauth_proxy.py +++ b/src/fastmcp/server/auth/oauth_proxy/proxy.py @@ -18,15 +18,12 @@ production use with enterprise identity providers. from __future__ import annotations -import base64 import hashlib -import hmac -import json import secrets import time from base64 import urlsafe_b64encode -from typing import TYPE_CHECKING, Any, Final -from urllib.parse import urlencode, urlparse +from typing import Any +from urllib.parse import urlencode import httpx from authlib.common.security import generate_token @@ -48,7 +45,7 @@ from mcp.server.auth.settings import ( RevocationOptions, ) from mcp.shared.auth import OAuthClientInformationFull, OAuthToken -from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, SecretStr +from pydantic import AnyHttpUrl, AnyUrl, SecretStr from starlette.requests import Request from starlette.responses import HTMLResponse, RedirectResponse from starlette.routing import Route @@ -61,457 +58,27 @@ from fastmcp.server.auth.jwt_issuer import ( JWTIssuer, derive_jwt_key, ) -from fastmcp.server.auth.redirect_validation import ( - validate_redirect_uri, +from fastmcp.server.auth.oauth_proxy.consent import ConsentMixin +from fastmcp.server.auth.oauth_proxy.models import ( + DEFAULT_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS, + DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS, + DEFAULT_AUTH_CODE_EXPIRY_SECONDS, + HTTP_TIMEOUT_SECONDS, + ClientCode, + JTIMapping, + OAuthTransaction, + ProxyDCRClient, + RefreshTokenMetadata, + UpstreamTokenSet, + _hash_token, ) +from fastmcp.server.auth.oauth_proxy.ui import create_error_html from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.ui import ( - BUTTON_STYLES, - DETAIL_BOX_STYLES, - DETAILS_STYLES, - INFO_BOX_STYLES, - REDIRECT_SECTION_STYLES, - TOOLTIP_STYLES, - 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_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS: Final[int] = ( - 60 * 60 * 24 * 365 -) # 1 year -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 stored in plaintext within this model. Encryption is handled transparently - at the storage layer via FernetEncryptionWrapper. Tokens are never exposed to MCP clients. - """ - - upstream_token_id: str # Unique ID for this token set - access_token: str # Upstream access token - refresh_token: str | None # 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 RefreshTokenMetadata(BaseModel): - """Metadata for a refresh token, stored keyed by token hash. - - We store only metadata (not the token itself) for security - if storage - is compromised, attackers get hashes they can't reverse into usable tokens. - """ - - client_id: str - scopes: list[str] - expires_at: int | None = None - created_at: float - - -def _hash_token(token: str) -> str: - """Hash a token for secure storage lookup. - - Uses SHA-256 to create a one-way hash. The original token cannot be - recovered from the hash, providing defense in depth if storage is compromised. - """ - return hashlib.sha256(token.encode()).hexdigest() - - -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 = "Application Access Request", - server_name: str | None = None, - server_icon_url: str | None = None, - server_website_url: str | None = None, - client_website_url: str | None = None, - csp_policy: str | None = None, -) -> str: - """Create a styled HTML consent page for OAuth authorization requests. - - Args: - csp_policy: Content Security Policy override. - If None, uses the built-in CSP policy with appropriate directives. - If empty string "", disables CSP entirely (no meta tag is rendered). - If a non-empty string, uses that as the CSP policy value. - """ - 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 - - # Build intro box with call-to-action - intro_box = f""" -The application {client_display} wants to access the MCP server {server_display}. Please ensure you recognize the callback address below.
-{error_message_escaped}
-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, - csp_policy=self._consent_csp_policy, - ) - 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/ui.py b/src/fastmcp/server/auth/oauth_proxy/ui.py new file mode 100644 index 000000000..3bae1a11c --- /dev/null +++ b/src/fastmcp/server/auth/oauth_proxy/ui.py @@ -0,0 +1,277 @@ +"""OAuth Proxy UI Generation Functions. + +This module contains HTML generation functions for consent and error pages. +""" + +from __future__ import annotations + +from urllib.parse import urlparse + +from fastmcp.utilities.ui import ( + BUTTON_STYLES, + DETAIL_BOX_STYLES, + DETAILS_STYLES, + INFO_BOX_STYLES, + REDIRECT_SECTION_STYLES, + TOOLTIP_STYLES, + create_logo, + create_page, +) + + +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 = "Application Access Request", + server_name: str | None = None, + server_icon_url: str | None = None, + server_website_url: str | None = None, + client_website_url: str | None = None, + csp_policy: str | None = None, +) -> str: + """Create a styled HTML consent page for OAuth authorization requests. + + Args: + csp_policy: Content Security Policy override. + If None, uses the built-in CSP policy with appropriate directives. + If empty string "", disables CSP entirely (no meta tag is rendered). + If a non-empty string, uses that as the CSP policy value. + """ + 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 + + # Build intro box with call-to-action + intro_box = f""" +The application {client_display} wants to access the MCP server {server_display}. Please ensure you recognize the callback address below.
+{error_message_escaped}
+