From 4e06f0964ea20e4f025c620b2fa55cb8704d4430 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:36:11 +0200 Subject: [PATCH] Backport #4659 to release/3.x: bind CIMD assertion audience to the advertised token endpoint A bare-authority base_url renders with a trailing slash, so the expected aud was https://host//token while the metadata advertised https://host/token. (cherry picked from commit 0c1c42f15139cc431850400c25322f1761703e88) --- .../fastmcp/server/auth/oauth_proxy/proxy.py | 15 +++++++- .../auth/oauth_proxy/test_oauth_proxy.py | 36 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py index 33eea92cb..acda34c13 100644 --- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py +++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py @@ -699,6 +699,19 @@ class OAuthProxy(OAuthProvider, ConsentMixin): ) return self._jwt_issuer + @property + def token_endpoint_url(self) -> str: + """The token endpoint URL, as advertised in the authorization server metadata. + + A CIMD `private_key_jwt` assertion is bound to this URL as its `aud`, so + it must match the advertised `token_endpoint` byte-for-byte. The SDK's + `build_metadata` builds that URL by stripping any trailing slash from + `base_url` first, so this does too: pydantic renders a bare-authority + `base_url` with a trailing slash, which would otherwise expect an `aud` + of `https://example.com//token`. + """ + return f"{str(self.base_url).rstrip('/')}/token" + # ------------------------------------------------------------------------- # Upstream OAuth Client # ------------------------------------------------------------------------- @@ -2058,7 +2071,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): ): # Replace the token endpoint authenticator with one that supports # private_key_jwt for CIMD clients - token_endpoint_url = f"{self.base_url}/token" + token_endpoint_url = self.token_endpoint_url cimd_authenticator = PrivateKeyJWTClientAuthenticator( provider=self, cimd_manager=self._cimd_manager, diff --git a/tests/server/auth/oauth_proxy/test_oauth_proxy.py b/tests/server/auth/oauth_proxy/test_oauth_proxy.py index b221f3ccc..86f61cb1f 100644 --- a/tests/server/auth/oauth_proxy/test_oauth_proxy.py +++ b/tests/server/auth/oauth_proxy/test_oauth_proxy.py @@ -8,6 +8,7 @@ import pytest from authlib.integrations.httpx_client import AsyncOAuth2Client from key_value.aio.stores.memory import MemoryStore from starlette.applications import Starlette +from starlette.testclient import TestClient from fastmcp.server.auth.oauth_proxy import OAuthProxy from fastmcp.server.auth.oauth_proxy.models import OAuthTransaction @@ -412,3 +413,38 @@ class TestIdpCallbackErrorForwarding: ) assert response.status_code == 400 + + +class TestCIMDTokenEndpointAudience: + """The `aud` expected on a CIMD assertion matches the advertised token endpoint.""" + + @pytest.mark.parametrize( + "base_url", + ["https://api.example.com", "https://api.example.com/api"], + ) + def test_token_endpoint_url_matches_advertised_metadata( + self, jwt_verifier, base_url: str + ): + """A bare-authority base_url must not expect `aud` of `https://host//token`. + + CIMD is enabled by default, and a spec-following client binds its + private_key_jwt assertion to the `token_endpoint` the metadata + advertises. If the proxy expects a different string, every such client + is rejected with invalid_client. + """ + proxy = OAuthProxy( + upstream_authorization_endpoint="https://auth.example.com/authorize", + upstream_token_endpoint="https://auth.example.com/token", + upstream_client_id="client-123", + upstream_client_secret="secret-456", + token_verifier=jwt_verifier, + base_url=base_url, + jwt_signing_key="test-secret", + client_storage=MemoryStore(), + ) + + app = Starlette(routes=proxy.get_routes(mcp_path="/mcp")) + with TestClient(app) as client: + metadata = client.get("/.well-known/oauth-authorization-server").json() + + assert proxy.token_endpoint_url == metadata["token_endpoint"]