mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
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:
parent
6c4ba6b420
commit
0c1c42f151
2 changed files with 50 additions and 1 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue