Bind CIMD assertion audience to the advertised token endpoint (#4659)

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.
This commit is contained in:
Jeremiah Lowin 2026-07-27 11:46:03 -04:00 committed by GitHub
commit 0c1c42f151
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 50 additions and 1 deletions

View file

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

View file

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