From 3dd73736808f9f59261cf19d30d7cabf759c1a8a Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 6 Sep 2025 15:50:29 -0400 Subject: [PATCH] feat: Add flexible parameter forwarding to OAuth proxy (#1771) --- docs/servers/auth/oauth-proxy.mdx | 64 ++++++++++ src/fastmcp/server/auth/oauth_proxy.py | 62 +++++++-- tests/server/auth/test_oauth_proxy.py | 168 +++++++++++++++++++++++++ 3 files changed, 283 insertions(+), 11 deletions(-) diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx index 9e01d92c6..1e31a33e7 100644 --- a/docs/servers/auth/oauth-proxy.mdx +++ b/docs/servers/auth/oauth-proxy.mdx @@ -147,8 +147,72 @@ mcp = FastMCP(name="My Server", auth=auth) List of all possible valid scopes for the OAuth provider. These are advertised to clients through the `/.well-known` endpoints. Defaults to `required_scopes` from your TokenVerifier if not specified. + + + Additional parameters to forward to the upstream authorization endpoint. Useful for provider-specific parameters that aren't part of the standard OAuth2 flow. + + For example, Auth0 requires an `audience` parameter to issue JWT tokens: + ```python + extra_authorize_params={"audience": "https://api.example.com"} + ``` + + These parameters are added to every authorization request sent to the upstream provider. + + + + Additional parameters to forward to the upstream token endpoint during code exchange and token refresh. Useful for provider-specific requirements during token operations. + + For example, some providers require additional context during token exchange: + ```python + extra_token_params={"audience": "https://api.example.com"} + ``` + + These parameters are included in all token requests to the upstream provider. + +### Provider-Specific Parameters + +Some OAuth providers require additional parameters beyond the standard OAuth2 flow. Use `extra_authorize_params` and `extra_token_params` to handle these requirements: + +#### Auth0 Example + +Auth0 requires an `audience` parameter to issue JWT tokens instead of opaque tokens: + +```python +auth = OAuthProxy( + upstream_authorization_endpoint="https://your-domain.auth0.com/authorize", + upstream_token_endpoint="https://your-domain.auth0.com/oauth/token", + upstream_client_id="your-auth0-client-id", + upstream_client_secret="your-auth0-client-secret", + + # Auth0 requires audience for JWT tokens + extra_authorize_params={ + "audience": "https://your-api-identifier.com" + }, + extra_token_params={ + "audience": "https://your-api-identifier.com" + }, + + token_verifier=JWTVerifier( + jwks_uri="https://your-domain.auth0.com/.well-known/jwks.json", + issuer="https://your-domain.auth0.com/", + audience="https://your-api-identifier.com" + ), + + base_url="https://your-server.com" +) +``` + +#### RFC 8707 Resource Indicators + +MCP clients can specify target resources using the standard `resource` parameter (RFC 8707). This is automatically forwarded when present: + +```python +# Client code (automatic - no server configuration needed) +# The resource parameter is passed through from AuthorizationParams +``` + ### Using Built-in Providers FastMCP includes pre-configured providers for common services: diff --git a/src/fastmcp/server/auth/oauth_proxy.py b/src/fastmcp/server/auth/oauth_proxy.py index 25feb4a64..9bd5813ce 100644 --- a/src/fastmcp/server/auth/oauth_proxy.py +++ b/src/fastmcp/server/auth/oauth_proxy.py @@ -250,6 +250,10 @@ class OAuthProxy(OAuthProvider): forward_pkce: bool = True, # Token endpoint authentication token_endpoint_auth_method: str | None = None, + # Extra parameters to forward to authorization endpoint + extra_authorize_params: dict[str, str] | None = None, + # Extra parameters to forward to token endpoint + extra_token_params: dict[str, str] | None = None, ): """Initialize the OAuth proxy provider. @@ -278,6 +282,11 @@ class OAuthProxy(OAuthProvider): 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"). + extra_authorize_params: Additional parameters to forward to the upstream authorization endpoint. + Useful for provider-specific parameters like Auth0's "audience". + Example: {"audience": "https://api.example.com"} + extra_token_params: Additional parameters to forward to the upstream token endpoint. + Useful for provider-specific parameters during token exchange. """ # Always enable DCR since we implement it locally for MCP clients client_registration_options = ClientRegistrationOptions( @@ -319,6 +328,10 @@ class OAuthProxy(OAuthProvider): # Token endpoint authentication self._token_endpoint_auth_method = token_endpoint_auth_method + # Extra parameters for authorization and token endpoints + self._extra_authorize_params = extra_authorize_params or {} + self._extra_token_params = extra_token_params or {} + # Local state for DCR and token bookkeeping self._clients: dict[str, OAuthClientInformationFull] = {} self._access_tokens: dict[str, AccessToken] = {} @@ -485,6 +498,24 @@ class OAuthProxy(OAuthProvider): txn_id, ) + # Forward resource parameter if provided (RFC 8707) + if params.resource: + query_params["resource"] = params.resource + logger.debug( + "Forwarding resource indicator '%s' to upstream for transaction %s", + params.resource, + txn_id, + ) + + # Add any extra authorization parameters configured for this proxy + if self._extra_authorize_params: + query_params.update(self._extra_authorize_params) + logger.debug( + "Adding extra authorization parameters for transaction %s: %s", + txn_id, + list(self._extra_authorize_params.keys()), + ) + # Build the upstream authorization URL separator = "&" if "?" in self._upstream_authorization_endpoint else "?" upstream_url = f"{self._upstream_authorization_endpoint}{separator}{urlencode(query_params)}" @@ -870,26 +901,35 @@ class OAuthProxy(OAuthProvider): 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, ) - idp_tokens: dict[str, Any] = await oauth_client.fetch_token( # type: ignore[misc] - url=self._upstream_token_endpoint, - code=idp_code, - redirect_uri=idp_redirect_uri, - code_verifier=proxy_code_verifier, - ) - else: - idp_tokens: dict[str, Any] = await oauth_client.fetch_token( # type: ignore[misc] - url=self._upstream_token_endpoint, - code=idp_code, - redirect_uri=idp_redirect_uri, + + # 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)})" ) diff --git a/tests/server/auth/test_oauth_proxy.py b/tests/server/auth/test_oauth_proxy.py index 3eb0af9ed..82b5e0aca 100644 --- a/tests/server/auth/test_oauth_proxy.py +++ b/tests/server/auth/test_oauth_proxy.py @@ -793,3 +793,171 @@ class TestOAuthProxyE2E: txn_id = query_params["state"][0] transaction = proxy._oauth_transactions[txn_id] assert "proxy_code_verifier" in transaction + + +class TestParameterForwarding: + """Tests for forwarding custom parameters to upstream OAuth provider.""" + + @pytest.fixture + def proxy_with_extra_params(self, jwt_verifier): + """Create OAuthProxy with extra parameters configured.""" + return OAuthProxy( + upstream_authorization_endpoint="https://oauth.example.com/authorize", + upstream_token_endpoint="https://oauth.example.com/token", + upstream_client_id="upstream-client", + upstream_client_secret="upstream-secret", + token_verifier=jwt_verifier, + base_url="https://proxy.example.com", + extra_authorize_params={"audience": "https://api.example.com"}, + extra_token_params={"audience": "https://api.example.com"}, + ) + + @pytest.fixture + def proxy_without_extra_params(self, jwt_verifier): + """Create OAuthProxy without extra parameters.""" + return OAuthProxy( + upstream_authorization_endpoint="https://oauth.example.com/authorize", + upstream_token_endpoint="https://oauth.example.com/token", + upstream_client_id="upstream-client", + upstream_client_secret="upstream-secret", + token_verifier=jwt_verifier, + base_url="https://proxy.example.com", + ) + + async def test_resource_parameter_forwarding(self, proxy_without_extra_params): + """Test that RFC 8707 resource parameter is forwarded from client request.""" + client = OAuthClientInformationFull( + client_id="test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:12345/callback"), + redirect_uri_provided_explicitly=True, + state="client-state", + code_challenge="client_challenge", + scopes=["read"], + resource="https://api.example.com/v1", # RFC 8707 resource indicator + ) + + redirect_url = await proxy_without_extra_params.authorize(client, params) + query_params = parse_qs(urlparse(redirect_url).query) + + # Resource parameter should be forwarded to upstream + assert "resource" in query_params + assert query_params["resource"][0] == "https://api.example.com/v1" + + async def test_extra_authorize_params(self, proxy_with_extra_params): + """Test that extra authorization parameters are included.""" + client = OAuthClientInformationFull( + client_id="test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:12345/callback"), + redirect_uri_provided_explicitly=True, + state="client-state", + code_challenge="client_challenge", + scopes=["read"], + ) + + redirect_url = await proxy_with_extra_params.authorize(client, params) + query_params = parse_qs(urlparse(redirect_url).query) + + # Extra audience parameter should be included + assert "audience" in query_params + assert query_params["audience"][0] == "https://api.example.com" + + async def test_resource_and_extra_params_together(self, proxy_with_extra_params): + """Test that both resource and extra params can be used together.""" + client = OAuthClientInformationFull( + client_id="test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:12345/callback"), + redirect_uri_provided_explicitly=True, + state="client-state", + code_challenge="client_challenge", + scopes=["read"], + resource="https://resource.example.com", # Client-specified resource + ) + + redirect_url = await proxy_with_extra_params.authorize(client, params) + query_params = parse_qs(urlparse(redirect_url).query) + + # Both resource and audience should be present + assert "resource" in query_params + assert query_params["resource"][0] == "https://resource.example.com" + assert "audience" in query_params + assert query_params["audience"][0] == "https://api.example.com" + + async def test_no_extra_params_when_not_configured( + self, proxy_without_extra_params + ): + """Test that no extra params are added when not configured.""" + client = OAuthClientInformationFull( + client_id="test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:12345/callback"), + redirect_uri_provided_explicitly=True, + state="client-state", + code_challenge="client_challenge", + scopes=["read"], + # No resource parameter + ) + + redirect_url = await proxy_without_extra_params.authorize(client, params) + query_params = parse_qs(urlparse(redirect_url).query) + + # No audience parameter should be present (not configured) + assert "audience" not in query_params + # No resource parameter should be present (not provided by client) + assert "resource" not in query_params + + async def test_multiple_extra_params(self, jwt_verifier): + """Test multiple extra parameters can be configured and forwarded.""" + proxy = OAuthProxy( + upstream_authorization_endpoint="https://oauth.example.com/authorize", + upstream_token_endpoint="https://oauth.example.com/token", + upstream_client_id="upstream-client", + upstream_client_secret="upstream-secret", + token_verifier=jwt_verifier, + base_url="https://proxy.example.com", + extra_authorize_params={ + "audience": "https://api.example.com", + "prompt": "consent", + "max_age": "3600", + }, + ) + + client = OAuthClientInformationFull( + client_id="test-client", + client_secret="test-secret", + redirect_uris=[AnyUrl("http://localhost:12345/callback")], + ) + + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:12345/callback"), + redirect_uri_provided_explicitly=True, + state="client-state", + code_challenge="client_challenge", + scopes=["read"], + ) + + redirect_url = await proxy.authorize(client, params) + query_params = parse_qs(urlparse(redirect_url).query) + + # All extra parameters should be included + assert query_params["audience"][0] == "https://api.example.com" + assert query_params["prompt"][0] == "consent" + assert query_params["max_age"][0] == "3600"