diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py index 539cce937..5632c8f65 100644 --- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py +++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py @@ -786,6 +786,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 # ------------------------------------------------------------------------- @@ -2406,7 +2419,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): # Replace the token endpoint so it can (a) authenticate CIMD # private_key_jwt clients and (b) accept the SEP-990 jwt-bearer # grant when identity assertion is enabled. - token_endpoint_url = f"{self.base_url}/token" + token_endpoint_url = self.token_endpoint_url if self._cimd_manager is not None: authenticator: ClientAuthenticator = ( PrivateKeyJWTClientAuthenticator( diff --git a/tests/server/auth/oauth_proxy/test_oauth_proxy.py b/tests/server/auth/oauth_proxy/test_oauth_proxy.py index 4e9289144..f5f15f5ab 100644 --- a/tests/server/auth/oauth_proxy/test_oauth_proxy.py +++ b/tests/server/auth/oauth_proxy/test_oauth_proxy.py @@ -10,6 +10,7 @@ from key_value.aio.stores.memory import MemoryStore from mcp.shared.auth import OAuthClientInformationFull from pydantic import AnyUrl 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 @@ -618,3 +619,38 @@ class TestIdpCallbackSuccessForwarding: # byte-for-byte. assert "flag" in query assert "sig=%FF%FE" in query + + +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"]