diff --git a/src/fastmcp/server/auth/handlers/authorize.py b/src/fastmcp/server/auth/handlers/authorize.py new file mode 100644 index 000000000..13c98a56d --- /dev/null +++ b/src/fastmcp/server/auth/handlers/authorize.py @@ -0,0 +1,324 @@ +"""Enhanced authorization handler with improved error responses. + +This module provides an enhanced authorization handler that wraps the MCP SDK's +AuthorizationHandler to provide better error messages when clients attempt to +authorize with unregistered client IDs. + +The enhancement adds: +- Content negotiation: HTML for browsers, JSON for API clients +- Enhanced JSON responses with registration endpoint hints +- Styled HTML error pages with registration links/forms +- Link headers pointing to registration endpoints +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from mcp.server.auth.handlers.authorize import ( + AuthorizationHandler as SDKAuthorizationHandler, +) +from pydantic import AnyHttpUrl +from starlette.requests import Request +from starlette.responses import Response + +from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.ui import ( + INFO_BOX_STYLES, + TOOLTIP_STYLES, + create_logo, + create_page, + create_secure_html_response, +) + +if TYPE_CHECKING: + from mcp.server.auth.provider import OAuthAuthorizationServerProvider + +logger = get_logger(__name__) + + +def create_unregistered_client_html( + client_id: str, + registration_endpoint: str, + discovery_endpoint: str, + server_name: str | None = None, + server_icon_url: str | None = None, + title: str = "Client Not Registered", +) -> str: + """Create styled HTML error page for unregistered client attempts. + + Args: + client_id: The unregistered client ID that was provided + registration_endpoint: URL of the registration endpoint + discovery_endpoint: URL of the OAuth metadata discovery endpoint + server_name: Optional server name for branding + server_icon_url: Optional server icon URL + title: Page title + + Returns: + HTML string for the error page + """ + import html as html_module + + client_id_escaped = html_module.escape(client_id) + + # Main error message + error_box = f""" +
+

The client ID {client_id_escaped} was not found in the server's client registry.

+
+ """ + + # What to do - yellow warning box + warning_box = """ +
+

Your MCP client opened this page to complete OAuth authorization, + but the server did not recognize its client ID. To fix this:

+ +
+ """ + + # Help link with tooltip (similar to consent screen) + help_link = """ + + """ + + # Build page content + content = f""" +
+ {create_logo(icon_url=server_icon_url, alt_text=server_name or "FastMCP")} +

{title}

+ {error_box} + {warning_box} +
+ {help_link} + """ + + # Use same styles as consent page + additional_styles = ( + INFO_BOX_STYLES + + TOOLTIP_STYLES + + """ + /* Error variant for info-box */ + .info-box.error { + background: #fef2f2; + border-color: #f87171; + } + .info-box.error strong { + color: #991b1b; + } + /* Warning variant for info-box (yellow) */ + .info-box.warning { + background: #fffbeb; + border-color: #fbbf24; + } + .info-box.warning strong { + color: #92400e; + } + .info-box code { + background: rgba(0, 0, 0, 0.05); + padding: 2px 6px; + border-radius: 3px; + font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; + font-size: 0.9em; + } + .info-box ul { + margin: 10px 0; + padding-left: 20px; + } + .info-box li { + margin: 6px 0; + } + """ + ) + + return create_page( + content=content, + title=title, + additional_styles=additional_styles, + ) + + +class AuthorizationHandler(SDKAuthorizationHandler): + """Authorization handler with enhanced error responses for unregistered clients. + + This handler extends the MCP SDK's AuthorizationHandler to provide better UX + when clients attempt to authorize without being registered. It implements + content negotiation to return: + + - HTML error pages for browser requests + - Enhanced JSON with registration hints for API clients + - Link headers pointing to registration endpoints + + This maintains OAuth 2.1 compliance (returns 400 for invalid client_id) + while providing actionable guidance to fix the error. + """ + + def __init__( + self, + provider: OAuthAuthorizationServerProvider, + base_url: AnyHttpUrl | str, + server_name: str | None = None, + server_icon_url: str | None = None, + ): + """Initialize the enhanced authorization handler. + + Args: + provider: OAuth authorization server provider + base_url: Base URL of the server for constructing endpoint URLs + server_name: Optional server name for branding + server_icon_url: Optional server icon URL for branding + """ + super().__init__(provider) + self._base_url = str(base_url).rstrip("/") + self._server_name = server_name + self._server_icon_url = server_icon_url + + async def handle(self, request: Request) -> Response: + """Handle authorization request with enhanced error responses. + + This method extends the SDK's authorization handler and intercepts + errors for unregistered clients to provide better error responses + based on the client's Accept header. + + Args: + request: The authorization request + + Returns: + Response (redirect on success, error response on failure) + """ + # Call the SDK handler + response = await super().handle(request) + + # Check if this is a client not found error + if response.status_code == 400: + # Try to extract client_id from request for enhanced error + client_id = None + if request.method == "GET": + client_id = request.query_params.get("client_id") + else: + form = await request.form() + client_id = form.get("client_id") + + # If we have a client_id and the error is about it not being found, + # enhance the response + if client_id: + try: + # Check if response body contains "not found" error + if hasattr(response, "body"): + import json + + body = json.loads(response.body) + if ( + body.get("error") == "invalid_request" + and "not found" in body.get("error_description", "").lower() + ): + return await self._create_enhanced_error_response( + request, client_id, body.get("state") + ) + except Exception: + # If we can't parse the response, just return the original + pass + + return response + + async def _create_enhanced_error_response( + self, request: Request, client_id: str, state: str | None + ) -> Response: + """Create enhanced error response with content negotiation. + + Args: + request: The original request + client_id: The unregistered client ID + state: The state parameter from the request + + Returns: + HTML or JSON error response based on Accept header + """ + registration_endpoint = f"{self._base_url}/register" + discovery_endpoint = f"{self._base_url}/.well-known/oauth-authorization-server" + + # Extract server metadata from app state (same pattern as consent screen) + from fastmcp.server.server import FastMCP + + 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 + else: + server_name = self._server_name + server_icon_url = self._server_icon_url + + # Check Accept header for content negotiation + accept = request.headers.get("accept", "") + + # Prefer HTML for browsers + if "text/html" in accept: + html = create_unregistered_client_html( + client_id=client_id, + registration_endpoint=registration_endpoint, + discovery_endpoint=discovery_endpoint, + server_name=server_name, + server_icon_url=server_icon_url, + ) + response = create_secure_html_response(html, status_code=400) + else: + # Return enhanced JSON for API clients + from mcp.server.auth.handlers.authorize import AuthorizationErrorResponse + + error_data = AuthorizationErrorResponse( + error="invalid_request", + error_description=( + f"Client ID '{client_id}' is not registered with this server. " + f"MCP clients should automatically re-register by sending a POST request to " + f"the registration_endpoint and retry authorization. " + f"If this persists, clear cached authentication tokens and reconnect." + ), + state=state, + ) + + # Add extra fields to help clients discover registration + error_dict = error_data.model_dump(exclude_none=True) + error_dict["registration_endpoint"] = registration_endpoint + error_dict["authorization_server_metadata"] = discovery_endpoint + + from starlette.responses import JSONResponse + + response = JSONResponse( + status_code=400, + content=error_dict, + headers={"Cache-Control": "no-store"}, + ) + + # Add Link header for registration endpoint discovery + response.headers["Link"] = ( + f'<{registration_endpoint}>; rel="http://oauth.net/core/2.1/#registration"' + ) + + logger.info( + "Unregistered client_id=%s, returned %s error response", + client_id, + "HTML" if "text/html" in accept else "JSON", + ) + + return response diff --git a/src/fastmcp/server/auth/middleware.py b/src/fastmcp/server/auth/middleware.py new file mode 100644 index 000000000..a7e80a7ff --- /dev/null +++ b/src/fastmcp/server/auth/middleware.py @@ -0,0 +1,96 @@ +"""Enhanced authentication middleware with better error messages. + +This module provides enhanced versions of MCP SDK authentication middleware +that return more helpful error messages for developers troubleshooting +authentication issues. +""" + +from __future__ import annotations + +import json + +from mcp.server.auth.middleware.bearer_auth import ( + RequireAuthMiddleware as SDKRequireAuthMiddleware, +) +from starlette.types import Send + +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +class RequireAuthMiddleware(SDKRequireAuthMiddleware): + """Enhanced authentication middleware with detailed error messages. + + Extends the SDK's RequireAuthMiddleware to provide more actionable + error messages when authentication fails. This helps developers + understand what went wrong and how to fix it. + """ + + async def _send_auth_error( + self, send: Send, status_code: int, error: str, description: str + ) -> None: + """Send an authentication error response with enhanced error messages. + + Overrides the SDK's _send_auth_error to provide more detailed + error descriptions that help developers troubleshoot authentication + issues. + + Args: + send: ASGI send callable + status_code: HTTP status code (401 or 403) + error: OAuth error code + description: Base error description + """ + # Enhance error descriptions based on error type + enhanced_description = description + + if error == "invalid_token" and status_code == 401: + # This is the "Authentication required" error + enhanced_description = ( + "Authentication failed. The provided bearer token is invalid, expired, or no longer recognized by the server. " + "To resolve: clear authentication tokens in your MCP client and reconnect. " + "Your client should automatically re-register and obtain new tokens." + ) + elif error == "insufficient_scope": + # Scope error - already has good detail from SDK + pass + + # Build WWW-Authenticate header value + www_auth_parts = [ + f'error="{error}"', + f'error_description="{enhanced_description}"', + ] + if self.resource_metadata_url: + www_auth_parts.append(f'resource_metadata="{self.resource_metadata_url}"') + + www_authenticate = f"Bearer {', '.join(www_auth_parts)}" + + # Send response + body = {"error": error, "error_description": enhanced_description} + body_bytes = json.dumps(body).encode() + + await send( + { + "type": "http.response.start", + "status": status_code, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body_bytes)).encode()), + (b"www-authenticate", www_authenticate.encode()), + ], + } + ) + + await send( + { + "type": "http.response.body", + "body": body_bytes, + } + ) + + logger.info( + "Auth error returned: %s (status=%d)", + error, + status_code, + ) diff --git a/src/fastmcp/server/auth/oauth_proxy.py b/src/fastmcp/server/auth/oauth_proxy.py index 9088a6659..aaa06be56 100644 --- a/src/fastmcp/server/auth/oauth_proxy.py +++ b/src/fastmcp/server/auth/oauth_proxy.py @@ -60,6 +60,7 @@ from starlette.routing import Route from fastmcp import settings from fastmcp.server.auth.auth import OAuthProvider, TokenVerifier +from fastmcp.server.auth.handlers.authorize import AuthorizationHandler from fastmcp.server.auth.jwt_issuer import ( JWTIssuer, TokenEncryption, @@ -1590,10 +1591,11 @@ class OAuthProxy(OAuthProvider): self, mcp_path: str | None = None, ) -> list[Route]: - """Get OAuth routes with custom proxy token handler. + """Get OAuth routes with custom handlers for better error UX. - This method creates standard OAuth routes and replaces the token endpoint - with our proxy handler that forwards requests to the upstream OAuth server. + This method creates standard OAuth routes and replaces: + - /authorize endpoint: Enhanced error responses for unregistered clients + - /token endpoint: OAuth 2.1 compliant error codes Args: mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") @@ -1603,6 +1605,7 @@ class OAuthProxy(OAuthProvider): routes = super().get_routes(mcp_path) custom_routes = [] token_route_found = False + authorize_route_found = False logger.debug( f"get_routes called - configuring OAuth routes in {len(routes)} routes" @@ -1613,8 +1616,30 @@ class OAuthProxy(OAuthProvider): f"Route {i}: {route} - path: {getattr(route, 'path', 'N/A')}, methods: {getattr(route, 'methods', 'N/A')}" ) - # Replace the token endpoint with our custom handler that returns proper OAuth 2.1 error codes + # Replace the authorize endpoint with our enhanced handler for better error UX if ( + isinstance(route, Route) + and route.path == "/authorize" + and route.methods is not None + and ("GET" in route.methods or "POST" in route.methods) + ): + authorize_route_found = True + # Replace with our enhanced authorization handler + authorize_handler = AuthorizationHandler( + provider=self, + base_url=self.base_url, + server_name=None, # Could be extended to pass server metadata + server_icon_url=None, + ) + custom_routes.append( + Route( + path="/authorize", + endpoint=authorize_handler.handle, + methods=["GET", "POST"], + ) + ) + # Replace the token endpoint with our custom handler that returns proper OAuth 2.1 error codes + elif ( isinstance(route, Route) and route.path == "/token" and route.methods is not None @@ -1658,7 +1683,7 @@ class OAuthProxy(OAuthProvider): ) logger.debug( - f"✅ OAuth routes configured: token_endpoint={token_route_found}, total routes={len(custom_routes)} (includes OAuth callback + consent)" + f"✅ OAuth routes configured: authorize_endpoint={authorize_route_found}, token_endpoint={token_route_found}, total routes={len(custom_routes)} (includes OAuth callback + consent)" ) return custom_routes diff --git a/src/fastmcp/server/http.py b/src/fastmcp/server/http.py index d7c3bc698..2ac186761 100644 --- a/src/fastmcp/server/http.py +++ b/src/fastmcp/server/http.py @@ -5,7 +5,6 @@ from contextlib import asynccontextmanager, contextmanager from contextvars import ContextVar from typing import TYPE_CHECKING -from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware from mcp.server.auth.routes import build_resource_metadata_url from mcp.server.lowlevel.server import LifespanResultT from mcp.server.sse import SseServerTransport @@ -21,6 +20,7 @@ from starlette.routing import BaseRoute, Mount, Route from starlette.types import Lifespan, Receive, Scope, Send from fastmcp.server.auth import AuthProvider +from fastmcp.server.auth.middleware import RequireAuthMiddleware from fastmcp.utilities.logging import get_logger if TYPE_CHECKING: diff --git a/tests/server/auth/test_enhanced_error_responses.py b/tests/server/auth/test_enhanced_error_responses.py new file mode 100644 index 000000000..4f345b38d --- /dev/null +++ b/tests/server/auth/test_enhanced_error_responses.py @@ -0,0 +1,362 @@ +"""Tests for enhanced OAuth error responses. + +This test suite covers: +1. Enhanced authorization handler (HTML and JSON error pages) +2. Enhanced middleware (better error messages) +3. Content negotiation +4. Server branding in error pages +""" + +import pytest +from mcp.shared.auth import OAuthClientInformationFull +from pydantic import AnyUrl +from starlette.applications import Starlette +from starlette.testclient import TestClient + +from fastmcp import FastMCP +from fastmcp.server.auth.oauth_proxy import OAuthProxy +from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair + + +class TestEnhancedAuthorizationHandler: + """Tests for enhanced authorization handler error responses.""" + + @pytest.fixture + def rsa_key_pair(self) -> RSAKeyPair: + """Generate RSA key pair for testing.""" + return RSAKeyPair.generate() + + @pytest.fixture + def oauth_proxy(self, rsa_key_pair): + """Create OAuth proxy for testing.""" + return OAuthProxy( + upstream_authorization_endpoint="https://github.com/login/oauth/authorize", + upstream_token_endpoint="https://github.com/login/oauth/access_token", + upstream_client_id="test-client-id", + upstream_client_secret="test-client-secret", + token_verifier=JWTVerifier( + public_key=rsa_key_pair.public_key, + issuer="https://test.com", + audience="https://test.com", + base_url="https://test.com", + ), + base_url="https://myserver.com", + ) + + def test_unregistered_client_returns_html_for_browser(self, oauth_proxy): + """Test that unregistered client returns styled HTML for browser requests.""" + app = Starlette(routes=oauth_proxy.get_routes()) + + with TestClient(app) as client: + response = client.get( + "/authorize", + params={ + "client_id": "unregistered-client-id", + "redirect_uri": "http://localhost:12345/callback", + "response_type": "code", + "code_challenge": "test-challenge", + "state": "test-state", + }, + headers={"Accept": "text/html"}, + ) + + # Should return 400 with HTML content + assert response.status_code == 400 + assert "text/html" in response.headers["content-type"] + + # HTML should contain error message + html = response.text + assert "Client Not Registered" in html + assert "unregistered-client-id" in html + assert "To fix this" in html + assert "Close this browser window" in html + assert "Clear authentication tokens" in html + + # Should have Link header for registration endpoint + assert "Link" in response.headers + assert "/register" in response.headers["Link"] + + def test_unregistered_client_returns_json_for_api(self, oauth_proxy): + """Test that unregistered client returns enhanced JSON for API clients.""" + app = Starlette(routes=oauth_proxy.get_routes()) + + with TestClient(app) as client: + response = client.get( + "/authorize", + params={ + "client_id": "unregistered-client-id", + "redirect_uri": "http://localhost:12345/callback", + "response_type": "code", + "code_challenge": "test-challenge", + "state": "test-state", + }, + headers={"Accept": "application/json"}, + ) + + # Should return 400 with JSON content + assert response.status_code == 400 + assert "application/json" in response.headers["content-type"] + + # JSON should have enhanced error response + data = response.json() + assert data["error"] == "invalid_request" + assert "unregistered-client-id" in data["error_description"] + assert data["state"] == "test-state" + + # Should include registration endpoint hints + assert "registration_endpoint" in data + assert data["registration_endpoint"] == "https://myserver.com/register" + assert "authorization_server_metadata" in data + + # Should have Link header + assert "Link" in response.headers + assert "/register" in response.headers["Link"] + + def test_successful_authorization_not_enhanced(self, oauth_proxy): + """Test that successful authorizations are not modified by enhancement.""" + app = Starlette(routes=oauth_proxy.get_routes()) + + # Register a valid client first + client_info = OAuthClientInformationFull( + client_id="valid-client", + client_secret="valid-secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + + # Need to register synchronously + import asyncio + + asyncio.run(oauth_proxy.register_client(client_info)) + + with TestClient(app) as client: + response = client.get( + "/authorize", + params={ + "client_id": "valid-client", + "redirect_uri": "http://localhost:12345/callback", + "response_type": "code", + "code_challenge": "test-challenge", + "state": "test-state", + }, + headers={"Accept": "text/html"}, + follow_redirects=False, + ) + + # Should redirect to consent page (302), not return error + assert response.status_code == 302 + assert "/consent" in response.headers["location"] + + def test_html_error_includes_server_branding(self, oauth_proxy): + """Test that HTML error page includes server branding from FastMCP instance.""" + from mcp.types import Icon + + # Create FastMCP server with custom branding + mcp = FastMCP( + "My Custom Server", + icons=[Icon(src="https://example.com/icon.png", mimeType="image/png")], + ) + + # Create app with OAuth routes + app = Starlette(routes=oauth_proxy.get_routes()) + # Attach FastMCP instance to app state (same as done in http.py) + app.state.fastmcp_server = mcp + + with TestClient(app) as client: + response = client.get( + "/authorize", + params={ + "client_id": "unregistered-client-id", + "redirect_uri": "http://localhost:12345/callback", + "response_type": "code", + "code_challenge": "test-challenge", + }, + headers={"Accept": "text/html"}, + ) + + assert response.status_code == 400 + html = response.text + + # Should include custom server icon + assert "https://example.com/icon.png" in html + + +class TestEnhancedRequireAuthMiddleware: + """Tests for enhanced authentication middleware error messages.""" + + @pytest.fixture + def rsa_key_pair(self) -> RSAKeyPair: + """Generate RSA key pair for testing.""" + return RSAKeyPair.generate() + + @pytest.fixture + def jwt_verifier(self, rsa_key_pair): + """Create JWT verifier for testing.""" + return JWTVerifier( + public_key=rsa_key_pair.public_key, + issuer="https://test.com", + audience="https://test.com", + base_url="https://test.com", + ) + + def test_invalid_token_enhanced_error_message(self, jwt_verifier): + """Test that invalid_token errors have enhanced error messages.""" + from fastmcp.server.http import create_streamable_http_app + + server = FastMCP("Test Server") + + @server.tool + def test_tool() -> str: + return "test" + + app = create_streamable_http_app( + server=server, + streamable_http_path="/mcp", + auth=jwt_verifier, + ) + + with TestClient(app) as client: + # Request without Authorization header + response = client.post("/mcp") + + assert response.status_code == 401 + assert "www-authenticate" in response.headers + + # Check enhanced error message + data = response.json() + assert data["error"] == "invalid_token" + # Should have enhanced description with resolution steps + assert "clear authentication tokens" in data["error_description"] + assert "automatically re-register" in data["error_description"] + + def test_invalid_token_www_authenticate_header_format(self, jwt_verifier): + """Test that WWW-Authenticate header format matches SDK.""" + from fastmcp.server.http import create_streamable_http_app + + server = FastMCP("Test Server") + app = create_streamable_http_app( + server=server, + streamable_http_path="/mcp", + auth=jwt_verifier, + ) + + with TestClient(app) as client: + response = client.post("/mcp") + + assert response.status_code == 401 + www_auth = response.headers["www-authenticate"] + + # Should follow Bearer challenge format + assert www_auth.startswith("Bearer ") + assert 'error="invalid_token"' in www_auth + assert "error_description=" in www_auth + + def test_insufficient_scope_not_enhanced(self, rsa_key_pair): + """Test that insufficient_scope errors are not modified.""" + # Create a valid token with wrong scopes + from fastmcp.server.http import create_streamable_http_app + + jwt_verifier = JWTVerifier( + public_key=rsa_key_pair.public_key, + issuer="https://test.com", + audience="https://test.com", + base_url="https://test.com", + ) + + server = FastMCP("Test Server") + + @server.tool + def test_tool() -> str: + return "test" + + app = create_streamable_http_app( + server=server, + streamable_http_path="/mcp", + auth=jwt_verifier, + ) + + # Note: Testing insufficient_scope would require mocking the verifier + # to return a token with wrong scopes. For now, we verify the middleware + # is properly in place by checking it rejects unauthenticated requests. + with TestClient(app) as client: + response = client.post("/mcp") + # Without a valid token, we get invalid_token + assert response.status_code == 401 + + +class TestContentNegotiation: + """Tests for content negotiation in error responses.""" + + @pytest.fixture + def oauth_proxy(self): + """Create OAuth proxy for testing.""" + return OAuthProxy( + upstream_authorization_endpoint="https://github.com/login/oauth/authorize", + upstream_token_endpoint="https://github.com/login/oauth/access_token", + upstream_client_id="test-client-id", + upstream_client_secret="test-client-secret", + token_verifier=JWTVerifier( + public_key=RSAKeyPair.generate().public_key, + issuer="https://test.com", + audience="https://test.com", + base_url="https://test.com", + ), + base_url="https://myserver.com", + ) + + def test_html_preferred_when_both_accepted(self, oauth_proxy): + """Test that HTML is preferred when both text/html and application/json are accepted.""" + app = Starlette(routes=oauth_proxy.get_routes()) + + with TestClient(app) as client: + response = client.get( + "/authorize", + params={ + "client_id": "unregistered-client-id", + "redirect_uri": "http://localhost:12345/callback", + "response_type": "code", + "code_challenge": "test-challenge", + }, + headers={"Accept": "text/html,application/json"}, + ) + + # Should prefer HTML + assert response.status_code == 400 + assert "text/html" in response.headers["content-type"] + + def test_json_when_only_json_accepted(self, oauth_proxy): + """Test that JSON is returned when only application/json is accepted.""" + app = Starlette(routes=oauth_proxy.get_routes()) + + with TestClient(app) as client: + response = client.get( + "/authorize", + params={ + "client_id": "unregistered-client-id", + "redirect_uri": "http://localhost:12345/callback", + "response_type": "code", + "code_challenge": "test-challenge", + }, + headers={"Accept": "application/json"}, + ) + + assert response.status_code == 400 + assert "application/json" in response.headers["content-type"] + + def test_json_when_no_accept_header(self, oauth_proxy): + """Test that JSON is returned when no Accept header is provided.""" + app = Starlette(routes=oauth_proxy.get_routes()) + + with TestClient(app) as client: + response = client.get( + "/authorize", + params={ + "client_id": "unregistered-client-id", + "redirect_uri": "http://localhost:12345/callback", + "response_type": "code", + "code_challenge": "test-challenge", + }, + ) + + # Without Accept header, should return JSON (API default) + assert response.status_code == 400 + assert "application/json" in response.headers["content-type"]