diff --git a/src/fastmcp/server/auth/oauth_proxy.py b/src/fastmcp/server/auth/oauth_proxy.py index 50dee0462..2d7dd6472 100644 --- a/src/fastmcp/server/auth/oauth_proxy.py +++ b/src/fastmcp/server/auth/oauth_proxy.py @@ -375,6 +375,96 @@ def create_consent_html( ) +def create_error_html( + error_title: str, + error_message: str, + error_details: dict[str, str] | None = None, + server_name: str | None = None, + server_icon_url: str | None = None, +) -> str: + """Create a styled HTML error page for OAuth errors. + + Args: + error_title: The error title (e.g., "OAuth Error", "Authorization Failed") + error_message: The main error message to display + error_details: Optional dictionary of error details to show (e.g., {"Error Code": "invalid_client"}) + server_name: Optional server name to display + server_icon_url: Optional URL to server icon/logo + + Returns: + Complete HTML page as a string + """ + import html as html_module + + error_message_escaped = html_module.escape(error_message) + + # Build error message box + error_box = f""" +
+

{error_message_escaped}

+
+ """ + + # Build error details section if provided + details_section = "" + if error_details: + detail_rows_html = "\n".join( + [ + f""" +
+
{html_module.escape(label)}:
+
{html_module.escape(value)}
+
+ """ + for label, value in error_details.items() + ] + ) + + details_section = f""" +
+ Error Details +
+ {detail_rows_html} +
+
+ """ + + # Build the page content + content = f""" +
+ {create_logo(icon_url=server_icon_url, alt_text=server_name or "FastMCP")} +

{html_module.escape(error_title)}

+ {error_box} + {details_section} +
+ """ + + # Additional styles needed for this page + # Override .info-box.error to use normal text color instead of red + additional_styles = ( + INFO_BOX_STYLES + + DETAILS_STYLES + + DETAIL_BOX_STYLES + + """ + .info-box.error { + color: #111827; + } + """ + ) + + # Simple CSP policy for error pages (no forms needed) + csp_policy = ( + "default-src 'none'; style-src 'unsafe-inline'; img-src https:; base-uri 'none'" + ) + + return create_page( + content=content, + title=error_title, + additional_styles=additional_styles, + csp_policy=csp_policy, + ) + + # ------------------------------------------------------------------------- # Handler Classes # ------------------------------------------------------------------------- @@ -1569,7 +1659,9 @@ class OAuthProxy(OAuthProvider): # IdP Callback Forwarding # ------------------------------------------------------------------------- - async def _handle_idp_callback(self, request: Request) -> RedirectResponse: + async def _handle_idp_callback( + self, request: Request + ) -> HTMLResponse | RedirectResponse: """Handle callback from upstream IdP and forward to client. This implements the DCR-compliant callback forwarding: @@ -1584,32 +1676,37 @@ class OAuthProxy(OAuthProvider): error = request.query_params.get("error") if error: + error_description = request.query_params.get("error_description") logger.error( "IdP callback error: %s - %s", error, - request.query_params.get("error_description"), + error_description, ) - # TODO: Forward error to client callback - return RedirectResponse( - url=f"data:text/html,

OAuth Error

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

", - status_code=302, + # Show error page to user + html_content = create_error_html( + error_title="OAuth Error", + error_message=f"Authentication failed: {error_description or 'Unknown error'}", + error_details={"Error Code": error} if error else None, ) + return HTMLResponse(content=html_content, status_code=400) if not idp_code or not txn_id: logger.error("IdP callback missing code or transaction ID") - return RedirectResponse( - url="data:text/html,

OAuth Error

Missing authorization code or transaction ID

", - status_code=302, + html_content = create_error_html( + error_title="OAuth Error", + error_message="Missing authorization code or transaction ID from the identity provider.", ) + return HTMLResponse(content=html_content, status_code=400) # Look up transaction data transaction_model = await self._transaction_store.get(key=txn_id) if not transaction_model: logger.error("IdP callback with invalid transaction ID: %s", txn_id) - return RedirectResponse( - url="data:text/html,

OAuth Error

Invalid or expired transaction

", - status_code=302, + html_content = create_error_html( + error_title="OAuth Error", + error_message="Invalid or expired authorization transaction. Please try authenticating again.", ) + return HTMLResponse(content=html_content, status_code=400) transaction = transaction_model.model_dump() # Exchange IdP code for tokens (server-side) @@ -1663,11 +1760,11 @@ class OAuthProxy(OAuthProvider): except Exception as e: logger.error("IdP token exchange failed: %s", e) - # TODO: Forward error to client callback - return RedirectResponse( - url=f"data:text/html,

OAuth Error

Token exchange failed: {e}

", - status_code=302, + html_content = create_error_html( + error_title="OAuth Error", + error_message=f"Token exchange with identity provider failed: {e}", ) + return HTMLResponse(content=html_content, status_code=500) # Generate our own authorization code for the client client_code = secrets.token_urlsafe(32) @@ -1714,10 +1811,11 @@ class OAuthProxy(OAuthProvider): except Exception as e: logger.error("Error in IdP callback handler: %s", e, exc_info=True) - return RedirectResponse( - url="data:text/html,

OAuth Error

Internal server error during IdP callback

", - status_code=302, + html_content = create_error_html( + error_title="OAuth Error", + error_message="Internal server error during OAuth callback processing. Please try again.", ) + return HTMLResponse(content=html_content, status_code=500) # ------------------------------------------------------------------------- # Consent Interstitial diff --git a/tests/server/auth/test_oauth_proxy.py b/tests/server/auth/test_oauth_proxy.py index e3bc09d2d..c4c7140e1 100644 --- a/tests/server/auth/test_oauth_proxy.py +++ b/tests/server/auth/test_oauth_proxy.py @@ -1309,3 +1309,103 @@ class TestTokenHandlerErrorTransformation: # Should pass through unchanged assert response.status_code == 400 assert b'"error":"invalid_grant"' in response.body + + +class TestErrorPageRendering: + """Test error page rendering for OAuth callback errors.""" + + def test_create_error_html_basic(self): + """Test basic error page generation.""" + from fastmcp.server.auth.oauth_proxy import create_error_html + + html = create_error_html( + error_title="Test Error", + error_message="This is a test error message", + ) + + # Verify it's valid HTML + assert "" in html + assert "Test Error" in html + assert "This is a test error message" in html + assert 'class="info-box error"' in html + + def test_create_error_html_with_details(self): + """Test error page with error details.""" + from fastmcp.server.auth.oauth_proxy import create_error_html + + html = create_error_html( + error_title="OAuth Error", + error_message="Authentication failed", + error_details={ + "Error Code": "invalid_scope", + "Description": "Requested scope does not exist", + }, + ) + + # Verify error details are included + assert "Error Details" in html + assert "Error Code" in html + assert "invalid_scope" in html + assert "Description" in html + assert "Requested scope does not exist" in html + + def test_create_error_html_escapes_user_input(self): + """Test that error page properly escapes HTML in user input.""" + from fastmcp.server.auth.oauth_proxy import create_error_html + + html = create_error_html( + error_title="Error ", + error_message="Message with HTML tags", + error_details={"Key" not in html + assert "<script>" in html + assert "HTML" not in html + assert "<b>HTML</b>" in html + + async def test_callback_error_returns_html_page(self): + """Test that OAuth callback errors return styled HTML instead of data: URLs.""" + from unittest.mock import Mock + + from starlette.requests import Request + from starlette.responses import HTMLResponse + + from fastmcp.server.auth.oauth_proxy import OAuthProxy + from fastmcp.server.auth.providers.jwt import JWTVerifier + + # Create a minimal OAuth proxy + provider = OAuthProxy( + upstream_authorization_endpoint="https://idp.example.com/authorize", + upstream_token_endpoint="https://idp.example.com/token", + upstream_client_id="test-client", + upstream_client_secret="test-secret", + token_verifier=JWTVerifier( + jwks_uri="https://idp.example.com/.well-known/jwks.json", + issuer="https://idp.example.com", + audience="test-client", + ), + base_url="http://localhost:8000", + jwt_signing_key="test-signing-key", + ) + + # Mock a request with an error from the IdP + mock_request = Mock(spec=Request) + mock_request.query_params = { + "error": "invalid_scope", + "error_description": "The application asked for scope 'read' that doesn't exist", + "state": "test-state", + } + + # Call the callback handler + response = await provider._handle_idp_callback(mock_request) + + # Verify we get an HTMLResponse, not a RedirectResponse + assert isinstance(response, HTMLResponse) + assert response.status_code == 400 + + # Verify the response contains the error message + assert b"invalid_scope" in response.body + assert b"doesn't exist" in response.body # HTML-escaped apostrophe + assert b"OAuth Error" in response.body