diff --git a/src/fastmcp/client/auth/cimd.py b/src/fastmcp/client/auth/cimd.py index ad19a0bf1..440a549fb 100644 --- a/src/fastmcp/client/auth/cimd.py +++ b/src/fastmcp/client/auth/cimd.py @@ -4,91 +4,150 @@ Utility for creating CIMD (Client ID Metadata Documents). CIMD allows OAuth clients to be identified by a URL pointing to their metadata document, eliminating the need for Dynamic Client Registration (DCR). -See SEP-991: https://github.com/modelcontextprotocol/modelcontextprotocol/issues/991 +See: +- SEP-991: https://github.com/modelcontextprotocol/modelcontextprotocol/issues/991 +- IETF Draft: https://www.ietf.org/archive/id/draft-ietf-oauth-client-id-metadata-document-00.html """ from __future__ import annotations -from mcp.shared.auth import OAuthClientInformationFull -from pydantic import AnyUrl +from typing import Any +from urllib.parse import urlparse + +from pydantic import AnyHttpUrl __all__ = ["create_cimd_document"] -# Common localhost redirect URIs for OAuth callbacks -DEFAULT_REDIRECT_URIS = [ - "http://localhost:8080/callback", - "http://localhost:8888/callback", - "http://localhost:9000/callback", - "http://127.0.0.1:8080/callback", - "http://127.0.0.1:8888/callback", - "http://127.0.0.1:9000/callback", -] + +def _validate_cimd_url(url: str) -> None: + """Validate URL meets CIMD requirements per IETF draft.""" + parsed = urlparse(url) + + if parsed.scheme != "https": + raise ValueError("CIMD URL must use HTTPS") + + if parsed.path in ("", "/"): + raise ValueError("CIMD URL must have a non-root path") + + if parsed.fragment: + raise ValueError("CIMD URL must not contain a fragment") + + if parsed.username or parsed.password: + raise ValueError("CIMD URL must not contain credentials") + + # Check for dot segments (. or ..) in path + path_segments = parsed.path.split("/") + if any(seg in (".", "..") for seg in path_segments): + raise ValueError("CIMD URL path must not contain dot segments") def create_cimd_document( url: str, *, + redirect_uris: list[str], client_name: str = "FastMCP Client", - redirect_uris: list[str] | None = None, scopes: list[str] | None = None, -) -> dict: + jwks_uri: str | None = None, + jwks: dict[str, Any] | None = None, + client_uri: str | None = None, + logo_uri: str | None = None, + contacts: list[str] | None = None, +) -> dict[str, Any]: """ Create a CIMD (Client ID Metadata Document) for hosting. The returned dict should be serialized as JSON and served at the given URL. The URL itself becomes the OAuth client_id. + For **public clients** (CLI apps, mobile apps), omit jwks_uri/jwks. + These clients use PKCE for security but cannot prove client identity. + + For **confidential clients**, provide jwks_uri or jwks containing your + public key(s). The client must sign JWTs with the corresponding private + key when calling the token endpoint, proving ownership of the client_id. + Args: url: The HTTPS URL where this document will be hosted. - Must use HTTPS and have a non-root path. + Must use HTTPS, have a non-root path, no fragment, no credentials. This exact URL becomes the client_id. - client_name: Human-readable name for the client. - redirect_uris: OAuth callback URIs. Defaults to common localhost variants - for development use. + redirect_uris: OAuth callback URIs. These must exactly match where your + OAuth client will receive callbacks. + client_name: Human-readable name for the client (displayed during consent). scopes: OAuth scopes to request (e.g., ["openid", "profile", "email"]). + jwks_uri: URL to JSON Web Key Set for confidential clients. + When provided, token_endpoint_auth_method is set to "private_key_jwt". + jwks: Inline JSON Web Key Set (alternative to jwks_uri). + When provided, token_endpoint_auth_method is set to "private_key_jwt". + client_uri: URL to client's homepage (displayed during consent). + logo_uri: URL to client's logo image (displayed during consent). + contacts: List of contact emails for the client developer. Returns: Dict ready to serialize as JSON and host at the URL. Raises: - ValueError: If URL doesn't use HTTPS or has no path. + ValueError: If URL is invalid or both jwks_uri and jwks are provided. - Example: - >>> import json + Example (public client): >>> doc = create_cimd_document( ... "https://example.com/.well-known/oauth-client.json", - ... client_name="My App", - ... scopes=["openid", "profile"], + ... redirect_uris=["http://localhost:8080/callback"], + ... client_name="My CLI App", ... ) - >>> print(json.dumps(doc, indent=2)) - { - "client_id": "https://example.com/.well-known/oauth-client.json", - "client_name": "My App", - "redirect_uris": ["http://localhost:8080/callback", ...], - "grant_types": ["authorization_code", "refresh_token"], - "response_types": ["code"], - "token_endpoint_auth_method": "none", - "scope": "openid profile" - } + >>> doc["token_endpoint_auth_method"] + 'none' + + Example (confidential client): + >>> doc = create_cimd_document( + ... "https://example.com/.well-known/oauth-client.json", + ... redirect_uris=["https://example.com/callback"], + ... client_name="My Web App", + ... jwks_uri="https://example.com/.well-known/jwks.json", + ... ) + >>> doc["token_endpoint_auth_method"] + 'private_key_jwt' """ - if not url.startswith("https://"): - raise ValueError("CIMD URL must use HTTPS") + _validate_cimd_url(url) - # Check for non-root path - from urllib.parse import urlparse + if jwks_uri and jwks: + raise ValueError("Provide either jwks_uri or jwks, not both") - parsed = urlparse(url) - if parsed.path in ("", "/"): - raise ValueError("CIMD URL must have a non-root path") + # Determine auth method based on whether keys are provided + is_confidential = jwks_uri is not None or jwks is not None + token_endpoint_auth_method = "private_key_jwt" if is_confidential else "none" - client = OAuthClientInformationFull( - client_id=url, - client_name=client_name, - redirect_uris=[AnyUrl(u) for u in (redirect_uris or DEFAULT_REDIRECT_URIS)], - grant_types=["authorization_code", "refresh_token"], - response_types=["code"], - token_endpoint_auth_method="none", - scope=" ".join(scopes) if scopes else None, - ) + # Build the document + # Using dict directly instead of OAuthClientInformationFull to include jwks fields + doc: dict[str, Any] = { + "client_id": url, + "client_name": client_name, + "redirect_uris": redirect_uris, + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": token_endpoint_auth_method, + } - return client.model_dump(mode="json", exclude_none=True) + # Add optional fields + if scopes: + doc["scope"] = " ".join(scopes) + + if jwks_uri: + doc["jwks_uri"] = jwks_uri + + if jwks: + doc["jwks"] = jwks + + if client_uri: + # Validate it's a valid URL + AnyHttpUrl(client_uri) + doc["client_uri"] = client_uri + + if logo_uri: + # Validate it's a valid URL + AnyHttpUrl(logo_uri) + doc["logo_uri"] = logo_uri + + if contacts: + doc["contacts"] = contacts + + return doc diff --git a/tests/client/auth/test_cimd.py b/tests/client/auth/test_cimd.py index 24de705b1..50407f80b 100644 --- a/tests/client/auth/test_cimd.py +++ b/tests/client/auth/test_cimd.py @@ -6,69 +6,232 @@ from fastmcp.client.auth import create_cimd_document class TestCreateCimdDocument: - def test_basic_creation(self): - doc = create_cimd_document("https://example.com/oauth/client.json") + """Tests for create_cimd_document utility.""" + + def test_basic_public_client(self): + """Public client with minimal required fields.""" + doc = create_cimd_document( + "https://example.com/oauth/client.json", + redirect_uris=["http://localhost:8080/callback"], + ) assert doc["client_id"] == "https://example.com/oauth/client.json" assert doc["client_name"] == "FastMCP Client" - assert doc["token_endpoint_auth_method"] == "none" + assert doc["redirect_uris"] == ["http://localhost:8080/callback"] assert doc["grant_types"] == ["authorization_code", "refresh_token"] assert doc["response_types"] == ["code"] - assert "redirect_uris" in doc - assert len(doc["redirect_uris"]) > 0 + assert doc["token_endpoint_auth_method"] == "none" def test_custom_client_name(self): doc = create_cimd_document( "https://example.com/oauth/client.json", + redirect_uris=["http://localhost:8080/callback"], client_name="My Custom Client", ) assert doc["client_name"] == "My Custom Client" - def test_custom_redirect_uris(self): + def test_multiple_redirect_uris(self): doc = create_cimd_document( "https://example.com/oauth/client.json", - redirect_uris=["http://localhost:3000/callback"], + redirect_uris=[ + "http://localhost:3000/callback", + "http://localhost:8080/callback", + ], ) - assert doc["redirect_uris"] == ["http://localhost:3000/callback"] + assert doc["redirect_uris"] == [ + "http://localhost:3000/callback", + "http://localhost:8080/callback", + ] def test_scopes(self): doc = create_cimd_document( "https://example.com/oauth/client.json", + redirect_uris=["http://localhost:8080/callback"], scopes=["openid", "profile", "email"], ) assert doc["scope"] == "openid profile email" def test_no_scopes_excludes_field(self): - doc = create_cimd_document("https://example.com/oauth/client.json") + doc = create_cimd_document( + "https://example.com/oauth/client.json", + redirect_uris=["http://localhost:8080/callback"], + ) assert "scope" not in doc + +class TestConfidentialClients: + """Tests for confidential client support with private_key_jwt.""" + + def test_jwks_uri_sets_private_key_jwt(self): + """Providing jwks_uri makes it a confidential client.""" + doc = create_cimd_document( + "https://example.com/oauth/client.json", + redirect_uris=["https://example.com/callback"], + jwks_uri="https://example.com/.well-known/jwks.json", + ) + + assert doc["token_endpoint_auth_method"] == "private_key_jwt" + assert doc["jwks_uri"] == "https://example.com/.well-known/jwks.json" + assert "jwks" not in doc + + def test_inline_jwks_sets_private_key_jwt(self): + """Providing inline jwks makes it a confidential client.""" + jwks = { + "keys": [ + { + "kty": "RSA", + "use": "sig", + "kid": "test-key-1", + "n": "0vx7agoebGcQSuuPiLJXZptN9...", + "e": "AQAB", + } + ] + } + doc = create_cimd_document( + "https://example.com/oauth/client.json", + redirect_uris=["https://example.com/callback"], + jwks=jwks, + ) + + assert doc["token_endpoint_auth_method"] == "private_key_jwt" + assert doc["jwks"] == jwks + assert "jwks_uri" not in doc + + def test_cannot_provide_both_jwks_uri_and_jwks(self): + """Cannot provide both jwks_uri and inline jwks.""" + with pytest.raises(ValueError, match="either jwks_uri or jwks"): + create_cimd_document( + "https://example.com/oauth/client.json", + redirect_uris=["https://example.com/callback"], + jwks_uri="https://example.com/.well-known/jwks.json", + jwks={"keys": []}, + ) + + +class TestOptionalMetadataFields: + """Tests for optional metadata fields.""" + + def test_client_uri(self): + doc = create_cimd_document( + "https://example.com/oauth/client.json", + redirect_uris=["http://localhost:8080/callback"], + client_uri="https://example.com", + ) + + assert doc["client_uri"] == "https://example.com" + + def test_logo_uri(self): + doc = create_cimd_document( + "https://example.com/oauth/client.json", + redirect_uris=["http://localhost:8080/callback"], + logo_uri="https://example.com/logo.png", + ) + + assert doc["logo_uri"] == "https://example.com/logo.png" + + def test_contacts(self): + doc = create_cimd_document( + "https://example.com/oauth/client.json", + redirect_uris=["http://localhost:8080/callback"], + contacts=["admin@example.com", "security@example.com"], + ) + + assert doc["contacts"] == ["admin@example.com", "security@example.com"] + + def test_invalid_client_uri_rejected(self): + with pytest.raises(Exception): # Pydantic validation error + create_cimd_document( + "https://example.com/oauth/client.json", + redirect_uris=["http://localhost:8080/callback"], + client_uri="not-a-valid-url", + ) + + def test_invalid_logo_uri_rejected(self): + with pytest.raises(Exception): # Pydantic validation error + create_cimd_document( + "https://example.com/oauth/client.json", + redirect_uris=["http://localhost:8080/callback"], + logo_uri="not-a-valid-url", + ) + + +class TestUrlValidation: + """Tests for CIMD URL validation per IETF draft.""" + def test_rejects_http_url(self): with pytest.raises(ValueError, match="must use HTTPS"): - create_cimd_document("http://example.com/oauth/client.json") + create_cimd_document( + "http://example.com/oauth/client.json", + redirect_uris=["http://localhost:8080/callback"], + ) def test_rejects_root_path(self): with pytest.raises(ValueError, match="non-root path"): - create_cimd_document("https://example.com/") + create_cimd_document( + "https://example.com/", + redirect_uris=["http://localhost:8080/callback"], + ) def test_rejects_no_path(self): with pytest.raises(ValueError, match="non-root path"): - create_cimd_document("https://example.com") + create_cimd_document( + "https://example.com", + redirect_uris=["http://localhost:8080/callback"], + ) - def test_well_known_path(self): - doc = create_cimd_document("https://example.com/.well-known/oauth-client.json") + def test_rejects_fragment(self): + with pytest.raises(ValueError, match="fragment"): + create_cimd_document( + "https://example.com/client.json#section", + redirect_uris=["http://localhost:8080/callback"], + ) + + def test_rejects_credentials(self): + with pytest.raises(ValueError, match="credentials"): + create_cimd_document( + "https://user:pass@example.com/client.json", + redirect_uris=["http://localhost:8080/callback"], + ) + + def test_rejects_dot_segments(self): + with pytest.raises(ValueError, match="dot segments"): + create_cimd_document( + "https://example.com/../client.json", + redirect_uris=["http://localhost:8080/callback"], + ) + + def test_rejects_single_dot_segment(self): + with pytest.raises(ValueError, match="dot segments"): + create_cimd_document( + "https://example.com/./client.json", + redirect_uris=["http://localhost:8080/callback"], + ) + + def test_well_known_path_allowed(self): + doc = create_cimd_document( + "https://example.com/.well-known/oauth-client.json", + redirect_uris=["http://localhost:8080/callback"], + ) assert doc["client_id"] == "https://example.com/.well-known/oauth-client.json" - def test_excludes_none_values(self): - doc = create_cimd_document("https://example.com/oauth/client.json") + def test_query_string_allowed(self): + """Query strings are discouraged but permitted.""" + doc = create_cimd_document( + "https://example.com/client.json?version=1", + redirect_uris=["http://localhost:8080/callback"], + ) - # These fields should not be present when None - assert "client_secret" not in doc - assert "client_id_issued_at" not in doc - assert "client_secret_expires_at" not in doc - assert "client_uri" not in doc - assert "logo_uri" not in doc + assert doc["client_id"] == "https://example.com/client.json?version=1" + + def test_port_allowed(self): + doc = create_cimd_document( + "https://example.com:8443/client.json", + redirect_uris=["http://localhost:8080/callback"], + ) + + assert doc["client_id"] == "https://example.com:8443/client.json"