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 0c1c42f151)
This commit is contained in:
Jeremiah Lowin 2026-08-07 10:36:11 +02:00 committed by Bernt Popp
commit 4e06f0964e
No known key found for this signature in database
2 changed files with 50 additions and 1 deletions

View file

@ -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,

View file

@ -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"]