fastmcp/tests/client/auth/test_cimd.py
Jeremiah Lowin e04b00b2f4 Enhance create_cimd_document with confidential client support
- Add jwks_uri and jwks parameters for private_key_jwt clients
- Add client_uri, logo_uri, contacts optional metadata fields
- Add strict URL validation per IETF draft (no fragments, credentials, dot segments)
- Make redirect_uris required (security: no defaults)

Note: private_key_jwt requires SDK server-side support (not yet available)
2025-12-06 11:49:33 -05:00

237 lines
8.3 KiB
Python

"""Tests for CIMD document creation utility."""
import pytest
from fastmcp.client.auth import create_cimd_document
class TestCreateCimdDocument:
"""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["redirect_uris"] == ["http://localhost:8080/callback"]
assert doc["grant_types"] == ["authorization_code", "refresh_token"]
assert doc["response_types"] == ["code"]
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_multiple_redirect_uris(self):
doc = create_cimd_document(
"https://example.com/oauth/client.json",
redirect_uris=[
"http://localhost:3000/callback",
"http://localhost:8080/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",
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",
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/",
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",
redirect_uris=["http://localhost:8080/callback"],
)
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_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"],
)
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"