From 8a48146aadab598eebc4f521dfc5765b54f656fa Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 26 Oct 2025 21:08:05 -0400 Subject: [PATCH] Cleanly render oauth errors from proxy (#2268) --- src/fastmcp/server/auth/oauth_proxy.py | 136 +++++++++++++++++++++---- tests/server/auth/test_oauth_proxy.py | 100 ++++++++++++++++++ 2 files changed, 217 insertions(+), 19 deletions(-) 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}
+{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,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,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,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,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 "