fastmcp/tests/server/auth/test_cimd.py
Jeremiah Lowin ad3b1b9d1b
Fix CIMD redirect allowlist bypass and cache revalidation (#3098)
* Harden CIMD redirect and cache handling

* Preserve CIMD cache policy on 304 revalidation

* Refresh 304 cache expiry from cached lifetime
2026-02-06 20:08:23 -05:00

1209 lines
44 KiB
Python

"""Unit tests for CIMD (Client ID Metadata Document) functionality."""
from __future__ import annotations
import time
from unittest.mock import AsyncMock, patch
import pytest
from pydantic import AnyHttpUrl, ValidationError
from fastmcp.server.auth.cimd import (
CIMDAssertionValidator,
CIMDClientManager,
CIMDDocument,
CIMDFetcher,
CIMDFetchError,
CIMDValidationError,
)
from fastmcp.server.auth.oauth_proxy.models import ProxyDCRClient
# Standard public IP used for DNS mocking in tests
TEST_PUBLIC_IP = "93.184.216.34"
class TestCIMDDocument:
"""Tests for CIMDDocument model validation."""
def test_valid_minimal_document(self):
"""Test that minimal valid document passes validation."""
doc = CIMDDocument(
client_id=AnyHttpUrl("https://example.com/client.json"),
redirect_uris=["http://localhost:3000/callback"],
)
assert str(doc.client_id) == "https://example.com/client.json"
assert doc.token_endpoint_auth_method == "none"
assert doc.grant_types == ["authorization_code"]
assert doc.response_types == ["code"]
def test_valid_full_document(self):
"""Test that full document passes validation."""
doc = CIMDDocument(
client_id=AnyHttpUrl("https://example.com/client.json"),
client_name="My App",
client_uri=AnyHttpUrl("https://example.com"),
logo_uri=AnyHttpUrl("https://example.com/logo.png"),
redirect_uris=["http://localhost:3000/callback"],
token_endpoint_auth_method="none",
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
scope="read write",
)
assert doc.client_name == "My App"
assert doc.scope == "read write"
def test_private_key_jwt_auth_method_allowed(self):
"""Test that private_key_jwt is allowed for CIMD."""
doc = CIMDDocument(
client_id=AnyHttpUrl("https://example.com/client.json"),
redirect_uris=["http://localhost:3000/callback"],
token_endpoint_auth_method="private_key_jwt",
jwks_uri=AnyHttpUrl("https://example.com/.well-known/jwks.json"),
)
assert doc.token_endpoint_auth_method == "private_key_jwt"
def test_client_secret_basic_rejected(self):
"""Test that client_secret_basic is rejected for CIMD."""
with pytest.raises(ValidationError) as exc_info:
CIMDDocument(
client_id=AnyHttpUrl("https://example.com/client.json"),
redirect_uris=["http://localhost:3000/callback"],
token_endpoint_auth_method="client_secret_basic", # type: ignore[arg-type] - testing invalid value
)
# Literal type rejects invalid values before custom validator
assert "token_endpoint_auth_method" in str(exc_info.value)
def test_client_secret_post_rejected(self):
"""Test that client_secret_post is rejected for CIMD."""
with pytest.raises(ValidationError) as exc_info:
CIMDDocument(
client_id=AnyHttpUrl("https://example.com/client.json"),
redirect_uris=["http://localhost:3000/callback"],
token_endpoint_auth_method="client_secret_post", # type: ignore[arg-type] - testing invalid value
)
assert "token_endpoint_auth_method" in str(exc_info.value)
def test_client_secret_jwt_rejected(self):
"""Test that client_secret_jwt is rejected for CIMD."""
with pytest.raises(ValidationError) as exc_info:
CIMDDocument(
client_id=AnyHttpUrl("https://example.com/client.json"),
redirect_uris=["http://localhost:3000/callback"],
token_endpoint_auth_method="client_secret_jwt", # type: ignore[arg-type] - testing invalid value
)
assert "token_endpoint_auth_method" in str(exc_info.value)
def test_missing_redirect_uris_rejected(self):
"""Test that redirect_uris is required for CIMD."""
with pytest.raises(ValidationError) as exc_info:
CIMDDocument(client_id=AnyHttpUrl("https://example.com/client.json"))
assert "redirect_uris" in str(exc_info.value)
def test_empty_redirect_uris_rejected(self):
"""Test that empty redirect_uris is rejected."""
with pytest.raises(ValidationError) as exc_info:
CIMDDocument(
client_id=AnyHttpUrl("https://example.com/client.json"),
redirect_uris=[],
)
assert "redirect_uris" in str(exc_info.value)
def test_redirect_uri_without_scheme_rejected(self):
"""Test that redirect_uris without a scheme are rejected."""
with pytest.raises(ValidationError, match="must have a scheme"):
CIMDDocument(
client_id=AnyHttpUrl("https://example.com/client.json"),
redirect_uris=["/just/a/path"],
)
def test_redirect_uri_without_host_rejected(self):
"""Test that redirect_uris without a host are rejected."""
with pytest.raises(ValidationError, match="must have a host"):
CIMDDocument(
client_id=AnyHttpUrl("https://example.com/client.json"),
redirect_uris=["http://"],
)
def test_redirect_uri_whitespace_only_rejected(self):
"""Test that whitespace-only redirect_uris are rejected."""
with pytest.raises(ValidationError, match="non-empty"):
CIMDDocument(
client_id=AnyHttpUrl("https://example.com/client.json"),
redirect_uris=[" "],
)
class TestCIMDFetcher:
"""Tests for CIMDFetcher."""
@pytest.fixture
def fetcher(self):
"""Create a CIMDFetcher for testing."""
return CIMDFetcher()
def test_is_cimd_client_id_valid_urls(self, fetcher: CIMDFetcher):
"""Test is_cimd_client_id accepts valid CIMD URLs."""
assert fetcher.is_cimd_client_id("https://example.com/client.json")
assert fetcher.is_cimd_client_id("https://example.com/path/to/client")
assert fetcher.is_cimd_client_id("https://sub.example.com/cimd.json")
def test_is_cimd_client_id_rejects_http(self, fetcher: CIMDFetcher):
"""Test is_cimd_client_id rejects HTTP URLs."""
assert not fetcher.is_cimd_client_id("http://example.com/client.json")
def test_is_cimd_client_id_rejects_root_path(self, fetcher: CIMDFetcher):
"""Test is_cimd_client_id rejects URLs with no path."""
assert not fetcher.is_cimd_client_id("https://example.com/")
assert not fetcher.is_cimd_client_id("https://example.com")
def test_is_cimd_client_id_rejects_non_url(self, fetcher: CIMDFetcher):
"""Test is_cimd_client_id rejects non-URL strings."""
assert not fetcher.is_cimd_client_id("client-123")
assert not fetcher.is_cimd_client_id("my-client")
assert not fetcher.is_cimd_client_id("")
assert not fetcher.is_cimd_client_id("not a url")
def test_validate_redirect_uri_exact_match(self, fetcher: CIMDFetcher):
"""Test redirect_uri validation with exact match."""
doc = CIMDDocument(
client_id=AnyHttpUrl("https://example.com/client.json"),
redirect_uris=["http://localhost:3000/callback"],
)
assert fetcher.validate_redirect_uri(doc, "http://localhost:3000/callback")
assert not fetcher.validate_redirect_uri(doc, "http://localhost:4000/callback")
def test_validate_redirect_uri_wildcard_match(self, fetcher: CIMDFetcher):
"""Test redirect_uri validation with wildcard port."""
doc = CIMDDocument(
client_id=AnyHttpUrl("https://example.com/client.json"),
redirect_uris=["http://localhost:*/callback"],
)
assert fetcher.validate_redirect_uri(doc, "http://localhost:3000/callback")
assert fetcher.validate_redirect_uri(doc, "http://localhost:8080/callback")
assert not fetcher.validate_redirect_uri(doc, "http://localhost:3000/other")
class TestCIMDFetcherHTTP:
"""Tests for CIMDFetcher HTTP fetching (using httpx mock).
Note: With SSRF protection and DNS pinning, HTTP requests go to the resolved IP
instead of the hostname. These tests mock DNS resolution to return a public IP
and configure httpx_mock to expect the IP-based URL.
"""
@pytest.fixture
def fetcher(self):
"""Create a CIMDFetcher for testing."""
return CIMDFetcher()
@pytest.fixture
def mock_dns(self):
"""Mock DNS resolution to return test public IP."""
with patch(
"fastmcp.server.auth.ssrf.resolve_hostname",
return_value=[TEST_PUBLIC_IP],
):
yield
async def test_fetch_success(self, fetcher: CIMDFetcher, httpx_mock, mock_dns):
"""Test successful CIMD document fetch."""
url = "https://example.com/client.json"
doc_data = {
"client_id": url,
"client_name": "Test App",
"redirect_uris": ["http://localhost:3000/callback"],
"token_endpoint_auth_method": "none",
}
# With DNS pinning, request goes to IP. Match any URL.
httpx_mock.add_response(
json=doc_data,
headers={
"content-type": "application/json",
"content-length": "200",
},
)
doc = await fetcher.fetch(url)
assert str(doc.client_id) == url
assert doc.client_name == "Test App"
async def test_fetch_ttl_cache(self, fetcher: CIMDFetcher, httpx_mock, mock_dns):
"""Test that fetched documents are cached and served from cache within TTL."""
url = "https://example.com/client.json"
doc_data = {
"client_id": url,
"client_name": "Test App",
"redirect_uris": ["http://localhost:3000/callback"],
"token_endpoint_auth_method": "none",
}
httpx_mock.add_response(
json=doc_data,
headers={"content-length": "200"},
)
first = await fetcher.fetch(url)
second = await fetcher.fetch(url)
assert first.client_id == second.client_id
assert len(httpx_mock.get_requests()) == 1
async def test_fetch_cache_control_max_age(
self, fetcher: CIMDFetcher, httpx_mock, mock_dns
):
"""Cache-Control max-age should prevent refetch before expiry."""
url = "https://example.com/client.json"
doc_data = {
"client_id": url,
"client_name": "Max-Age App",
"redirect_uris": ["http://localhost:3000/callback"],
"token_endpoint_auth_method": "none",
}
httpx_mock.add_response(
json=doc_data,
headers={"cache-control": "max-age=60", "content-length": "200"},
)
first = await fetcher.fetch(url)
second = await fetcher.fetch(url)
assert first.client_name == second.client_name
assert len(httpx_mock.get_requests()) == 1
async def test_fetch_etag_revalidation_304(
self, fetcher: CIMDFetcher, httpx_mock, mock_dns
):
"""Expired cache should revalidate with ETag and accept 304."""
url = "https://example.com/client.json"
doc_data = {
"client_id": url,
"client_name": "ETag App",
"redirect_uris": ["http://localhost:3000/callback"],
"token_endpoint_auth_method": "none",
}
httpx_mock.add_response(
json=doc_data,
headers={
"cache-control": "max-age=0",
"etag": '"v1"',
"content-length": "200",
},
)
httpx_mock.add_response(
status_code=304,
headers={
"cache-control": "max-age=120",
"etag": '"v1"',
"content-length": "0",
},
)
first = await fetcher.fetch(url)
second = await fetcher.fetch(url)
requests = httpx_mock.get_requests()
assert first.client_name == "ETag App"
assert second.client_name == "ETag App"
assert len(requests) == 2
assert requests[1].headers.get("if-none-match") == '"v1"'
async def test_fetch_last_modified_revalidation_304(
self, fetcher: CIMDFetcher, httpx_mock, mock_dns
):
"""Expired cache should revalidate with Last-Modified and accept 304."""
url = "https://example.com/client.json"
doc_data = {
"client_id": url,
"client_name": "Last-Modified App",
"redirect_uris": ["http://localhost:3000/callback"],
"token_endpoint_auth_method": "none",
}
last_modified = "Wed, 21 Oct 2015 07:28:00 GMT"
httpx_mock.add_response(
json=doc_data,
headers={
"cache-control": "max-age=0",
"last-modified": last_modified,
"content-length": "200",
},
)
httpx_mock.add_response(
status_code=304,
headers={"cache-control": "max-age=120", "content-length": "0"},
)
first = await fetcher.fetch(url)
second = await fetcher.fetch(url)
requests = httpx_mock.get_requests()
assert first.client_name == "Last-Modified App"
assert second.client_name == "Last-Modified App"
assert len(requests) == 2
assert requests[1].headers.get("if-modified-since") == last_modified
async def test_fetch_cache_control_no_store(
self, fetcher: CIMDFetcher, httpx_mock, mock_dns
):
"""Cache-Control no-store should prevent storing CIMD documents."""
url = "https://example.com/client.json"
doc_data = {
"client_id": url,
"client_name": "No-Store App",
"redirect_uris": ["http://localhost:3000/callback"],
"token_endpoint_auth_method": "none",
}
httpx_mock.add_response(
json=doc_data,
headers={"cache-control": "no-store", "content-length": "200"},
)
httpx_mock.add_response(
json=doc_data,
headers={"cache-control": "no-store", "content-length": "200"},
)
first = await fetcher.fetch(url)
second = await fetcher.fetch(url)
assert first.client_name == second.client_name
assert len(httpx_mock.get_requests()) == 2
async def test_fetch_cache_control_no_cache(
self, fetcher: CIMDFetcher, httpx_mock, mock_dns
):
"""Cache-Control no-cache should force revalidation on each fetch."""
url = "https://example.com/client.json"
doc_data = {
"client_id": url,
"client_name": "No-Cache App",
"redirect_uris": ["http://localhost:3000/callback"],
"token_endpoint_auth_method": "none",
}
httpx_mock.add_response(
json=doc_data,
headers={
"cache-control": "no-cache",
"etag": '"v2"',
"content-length": "200",
},
)
httpx_mock.add_response(
status_code=304,
headers={
"cache-control": "no-cache",
"etag": '"v2"',
"content-length": "0",
},
)
first = await fetcher.fetch(url)
second = await fetcher.fetch(url)
requests = httpx_mock.get_requests()
assert first.client_name == "No-Cache App"
assert second.client_name == "No-Cache App"
assert len(requests) == 2
assert requests[1].headers.get("if-none-match") == '"v2"'
async def test_fetch_304_without_cache_headers_preserves_policy(
self, fetcher: CIMDFetcher, httpx_mock, mock_dns
):
"""304 responses without cache headers should not reset cached policy."""
url = "https://example.com/client.json"
doc_data = {
"client_id": url,
"client_name": "No-Header-304 App",
"redirect_uris": ["http://localhost:3000/callback"],
"token_endpoint_auth_method": "none",
}
httpx_mock.add_response(
json=doc_data,
headers={
"cache-control": "no-cache",
"etag": '"v3"',
"content-length": "200",
},
)
# Intentionally omit cache-control/expires on 304.
httpx_mock.add_response(
status_code=304,
headers={"content-length": "0"},
)
httpx_mock.add_response(
status_code=304,
headers={"content-length": "0"},
)
first = await fetcher.fetch(url)
second = await fetcher.fetch(url)
third = await fetcher.fetch(url)
requests = httpx_mock.get_requests()
assert first.client_name == "No-Header-304 App"
assert second.client_name == "No-Header-304 App"
assert third.client_name == "No-Header-304 App"
assert len(requests) == 3
assert requests[1].headers.get("if-none-match") == '"v3"'
assert requests[2].headers.get("if-none-match") == '"v3"'
async def test_fetch_304_without_cache_headers_refreshes_cached_freshness(
self, fetcher: CIMDFetcher, httpx_mock, mock_dns
):
"""A header-less 304 should renew freshness using cached lifetime."""
url = "https://example.com/client.json"
doc_data = {
"client_id": url,
"client_name": "Headerless 304 Freshness App",
"redirect_uris": ["http://localhost:3000/callback"],
"token_endpoint_auth_method": "none",
}
httpx_mock.add_response(
json=doc_data,
headers={
"cache-control": "max-age=60",
"etag": '"v4"',
"content-length": "200",
},
)
httpx_mock.add_response(
status_code=304,
headers={"content-length": "0"},
)
first = await fetcher.fetch(url)
# Simulate cache expiry so the next request triggers revalidation.
cached_entry = fetcher._cache[url]
cached_entry.expires_at = time.time() - 1
second = await fetcher.fetch(url)
third = await fetcher.fetch(url)
requests = httpx_mock.get_requests()
assert first.client_name == "Headerless 304 Freshness App"
assert second.client_name == "Headerless 304 Freshness App"
assert third.client_name == "Headerless 304 Freshness App"
assert len(requests) == 2
assert requests[1].headers.get("if-none-match") == '"v4"'
async def test_fetch_client_id_mismatch(
self, fetcher: CIMDFetcher, httpx_mock, mock_dns
):
"""Test that client_id mismatch is rejected."""
url = "https://example.com/client.json"
doc_data = {
"client_id": "https://other.com/client.json", # Different URL
"client_name": "Test App",
"redirect_uris": ["http://localhost:3000/callback"],
}
httpx_mock.add_response(
json=doc_data,
headers={"content-length": "100"},
)
with pytest.raises(CIMDValidationError) as exc_info:
await fetcher.fetch(url)
assert "mismatch" in str(exc_info.value).lower()
async def test_fetch_http_error(self, fetcher: CIMDFetcher, httpx_mock, mock_dns):
"""Test handling of HTTP errors."""
url = "https://example.com/client.json"
httpx_mock.add_response(status_code=404)
with pytest.raises(CIMDFetchError) as exc_info:
await fetcher.fetch(url)
assert "404" in str(exc_info.value)
async def test_fetch_invalid_json(self, fetcher: CIMDFetcher, httpx_mock, mock_dns):
"""Test handling of invalid JSON response."""
url = "https://example.com/client.json"
httpx_mock.add_response(
content=b"not json",
headers={"content-length": "10"},
)
with pytest.raises(CIMDValidationError) as exc_info:
await fetcher.fetch(url)
assert "JSON" in str(exc_info.value)
async def test_fetch_invalid_document(
self, fetcher: CIMDFetcher, httpx_mock, mock_dns
):
"""Test handling of invalid CIMD document."""
url = "https://example.com/client.json"
doc_data = {
"client_id": url,
"redirect_uris": ["http://localhost:3000/callback"],
"token_endpoint_auth_method": "client_secret_basic", # Not allowed
}
httpx_mock.add_response(
json=doc_data,
headers={"content-length": "100"},
)
with pytest.raises(CIMDValidationError) as exc_info:
await fetcher.fetch(url)
assert "Invalid CIMD document" in str(exc_info.value)
class TestCIMDAssertionValidator:
"""Tests for CIMDAssertionValidator (private_key_jwt support)."""
@pytest.fixture
def validator(self):
"""Create a CIMDAssertionValidator for testing."""
return CIMDAssertionValidator()
@pytest.fixture
def key_pair(self):
"""Generate RSA key pair for testing."""
from fastmcp.server.auth.providers.jwt import RSAKeyPair
return RSAKeyPair.generate()
@pytest.fixture
def jwks(self, key_pair):
"""Create JWKS from key pair."""
import base64
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
# Load public key
public_key = serialization.load_pem_public_key(
key_pair.public_key.encode(), backend=default_backend()
)
# Get RSA public numbers
from cryptography.hazmat.primitives.asymmetric import rsa
if isinstance(public_key, rsa.RSAPublicKey):
numbers = public_key.public_numbers()
# Convert to JWK format
return {
"keys": [
{
"kty": "RSA",
"kid": "test-key-1",
"use": "sig",
"alg": "RS256",
"n": base64.urlsafe_b64encode(
numbers.n.to_bytes((numbers.n.bit_length() + 7) // 8, "big")
)
.rstrip(b"=")
.decode(),
"e": base64.urlsafe_b64encode(
numbers.e.to_bytes((numbers.e.bit_length() + 7) // 8, "big")
)
.rstrip(b"=")
.decode(),
}
]
}
@pytest.fixture
def cimd_doc_with_jwks_uri(self):
"""Create CIMD document with jwks_uri."""
return CIMDDocument(
client_id=AnyHttpUrl("https://example.com/client.json"),
redirect_uris=["http://localhost:3000/callback"],
token_endpoint_auth_method="private_key_jwt",
jwks_uri=AnyHttpUrl("https://example.com/.well-known/jwks.json"),
)
@pytest.fixture
def cimd_doc_with_inline_jwks(self, jwks):
"""Create CIMD document with inline JWKS."""
return CIMDDocument(
client_id=AnyHttpUrl("https://example.com/client.json"),
redirect_uris=["http://localhost:3000/callback"],
token_endpoint_auth_method="private_key_jwt",
jwks=jwks,
)
async def test_valid_assertion_with_jwks_uri(
self, validator, key_pair, cimd_doc_with_jwks_uri, httpx_mock
):
"""Test that valid JWT assertion passes validation (jwks_uri)."""
client_id = "https://example.com/client.json"
token_endpoint = "https://oauth.example.com/token"
# Mock JWKS endpoint
import base64
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
public_key = serialization.load_pem_public_key(
key_pair.public_key.encode(), backend=default_backend()
)
from cryptography.hazmat.primitives.asymmetric import rsa
assert isinstance(public_key, rsa.RSAPublicKey)
numbers = public_key.public_numbers()
jwks = {
"keys": [
{
"kty": "RSA",
"kid": "test-key-1",
"use": "sig",
"alg": "RS256",
"n": base64.urlsafe_b64encode(
numbers.n.to_bytes((numbers.n.bit_length() + 7) // 8, "big")
)
.rstrip(b"=")
.decode(),
"e": base64.urlsafe_b64encode(
numbers.e.to_bytes((numbers.e.bit_length() + 7) // 8, "big")
)
.rstrip(b"=")
.decode(),
}
]
}
# Mock DNS resolution for SSRF-safe fetch
with patch(
"fastmcp.server.auth.ssrf.resolve_hostname",
return_value=[TEST_PUBLIC_IP],
):
httpx_mock.add_response(json=jwks)
# Create valid assertion (use short lifetime for security compliance)
assertion = key_pair.create_token(
subject=client_id,
issuer=client_id,
audience=token_endpoint,
additional_claims={"jti": "unique-jti-123"},
expires_in_seconds=60, # 1 minute (max allowed is 300s)
kid="test-key-1",
)
# Should validate successfully
assert await validator.validate_assertion(
assertion, client_id, token_endpoint, cimd_doc_with_jwks_uri
)
async def test_valid_assertion_with_inline_jwks(
self, validator, key_pair, cimd_doc_with_inline_jwks
):
"""Test that valid JWT assertion passes validation (inline JWKS)."""
client_id = "https://example.com/client.json"
token_endpoint = "https://oauth.example.com/token"
# Create valid assertion (use short lifetime for security compliance)
assertion = key_pair.create_token(
subject=client_id,
issuer=client_id,
audience=token_endpoint,
additional_claims={"jti": "unique-jti-456"},
expires_in_seconds=60, # 1 minute (max allowed is 300s)
kid="test-key-1",
)
# Should validate successfully
assert await validator.validate_assertion(
assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
)
async def test_rejects_wrong_issuer(
self, validator, key_pair, cimd_doc_with_inline_jwks
):
"""Test that wrong issuer is rejected."""
client_id = "https://example.com/client.json"
token_endpoint = "https://oauth.example.com/token"
# Create assertion with wrong issuer
assertion = key_pair.create_token(
subject=client_id,
issuer="https://attacker.com", # Wrong!
audience=token_endpoint,
additional_claims={"jti": "unique-jti-789"},
expires_in_seconds=60,
kid="test-key-1",
)
with pytest.raises(ValueError) as exc_info:
await validator.validate_assertion(
assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
)
assert "Invalid JWT assertion" in str(exc_info.value)
async def test_rejects_wrong_audience(
self, validator, key_pair, cimd_doc_with_inline_jwks
):
"""Test that wrong audience is rejected."""
client_id = "https://example.com/client.json"
token_endpoint = "https://oauth.example.com/token"
# Create assertion with wrong audience
assertion = key_pair.create_token(
subject=client_id,
issuer=client_id,
audience="https://wrong-endpoint.com/token", # Wrong!
additional_claims={"jti": "unique-jti-abc"},
expires_in_seconds=60,
kid="test-key-1",
)
with pytest.raises(ValueError) as exc_info:
await validator.validate_assertion(
assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
)
assert "Invalid JWT assertion" in str(exc_info.value)
async def test_rejects_wrong_subject(
self, validator, key_pair, cimd_doc_with_inline_jwks
):
"""Test that wrong subject claim is rejected."""
client_id = "https://example.com/client.json"
token_endpoint = "https://oauth.example.com/token"
# Create assertion with wrong subject
assertion = key_pair.create_token(
subject="https://different-client.com", # Wrong!
issuer=client_id,
audience=token_endpoint,
additional_claims={"jti": "unique-jti-def"},
expires_in_seconds=60,
kid="test-key-1",
)
with pytest.raises(ValueError) as exc_info:
await validator.validate_assertion(
assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
)
assert "sub claim must be" in str(exc_info.value)
async def test_rejects_missing_jti(
self, validator, key_pair, cimd_doc_with_inline_jwks
):
"""Test that missing jti claim is rejected."""
client_id = "https://example.com/client.json"
token_endpoint = "https://oauth.example.com/token"
# Create assertion without jti
assertion = key_pair.create_token(
subject=client_id,
issuer=client_id,
audience=token_endpoint,
# No jti!
expires_in_seconds=60,
kid="test-key-1",
)
with pytest.raises(ValueError) as exc_info:
await validator.validate_assertion(
assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
)
assert "jti claim" in str(exc_info.value)
async def test_rejects_replayed_jti(
self, validator, key_pair, cimd_doc_with_inline_jwks
):
"""Test that replayed JTI is detected and rejected."""
client_id = "https://example.com/client.json"
token_endpoint = "https://oauth.example.com/token"
# Create assertion
assertion = key_pair.create_token(
subject=client_id,
issuer=client_id,
audience=token_endpoint,
additional_claims={"jti": "replayed-jti"},
expires_in_seconds=60,
kid="test-key-1",
)
# First use should succeed
assert await validator.validate_assertion(
assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
)
# Second use with same jti should fail (replay attack)
with pytest.raises(ValueError) as exc_info:
await validator.validate_assertion(
assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
)
assert "replay" in str(exc_info.value).lower()
async def test_rejects_expired_token(
self, validator, key_pair, cimd_doc_with_inline_jwks
):
"""Test that expired tokens are rejected."""
client_id = "https://example.com/client.json"
token_endpoint = "https://oauth.example.com/token"
# Create expired assertion (expired 1 hour ago)
assertion = key_pair.create_token(
subject=client_id,
issuer=client_id,
audience=token_endpoint,
additional_claims={"jti": "expired-jti"},
expires_in_seconds=-3600, # Negative = expired
kid="test-key-1",
)
with pytest.raises(ValueError) as exc_info:
await validator.validate_assertion(
assertion, client_id, token_endpoint, cimd_doc_with_inline_jwks
)
assert "Invalid JWT assertion" in str(exc_info.value)
class TestCIMDClientManager:
"""Tests for CIMDClientManager."""
@pytest.fixture
def manager(self):
"""Create a CIMDClientManager for testing."""
return CIMDClientManager(enable_cimd=True)
@pytest.fixture
def disabled_manager(self):
"""Create a disabled CIMDClientManager for testing."""
return CIMDClientManager(enable_cimd=False)
@pytest.fixture
def mock_dns(self):
"""Mock DNS resolution to return test public IP."""
with patch(
"fastmcp.server.auth.ssrf.resolve_hostname",
return_value=[TEST_PUBLIC_IP],
):
yield
def test_is_cimd_client_id_enabled(self, manager):
"""Test CIMD URL detection when enabled."""
assert manager.is_cimd_client_id("https://example.com/client.json")
assert not manager.is_cimd_client_id("regular-client-id")
def test_is_cimd_client_id_disabled(self, disabled_manager):
"""Test CIMD URL detection when disabled."""
assert not disabled_manager.is_cimd_client_id("https://example.com/client.json")
assert not disabled_manager.is_cimd_client_id("regular-client-id")
async def test_get_client_success(self, manager, httpx_mock, mock_dns):
"""Test successful CIMD client creation."""
url = "https://example.com/client.json"
doc_data = {
"client_id": url,
"client_name": "Test App",
"redirect_uris": ["http://localhost:3000/callback"],
"token_endpoint_auth_method": "none",
}
httpx_mock.add_response(
json=doc_data,
headers={"content-length": "200"},
)
client = await manager.get_client(url)
assert client is not None
assert client.client_id == url
assert client.client_name == "Test App"
# Verify it uses proxy's patterns (None by default), not document's redirect_uris
assert client.allowed_redirect_uri_patterns is None
async def test_get_client_disabled(self, disabled_manager):
"""Test that get_client returns None when disabled."""
client = await disabled_manager.get_client("https://example.com/client.json")
assert client is None
async def test_get_client_fetch_failure(self, manager, httpx_mock, mock_dns):
"""Test that get_client returns None on fetch failure."""
url = "https://example.com/client.json"
httpx_mock.add_response(status_code=404)
client = await manager.get_client(url)
assert client is None
# Trust policy and consent bypass tests removed - functionality removed from CIMD
class TestCIMDClientManagerGetClientOptions:
"""Tests for CIMDClientManager.get_client with default_scope and allowed patterns."""
@pytest.fixture
def mock_dns(self):
"""Mock DNS resolution to return test public IP."""
with patch(
"fastmcp.server.auth.ssrf.resolve_hostname",
return_value=[TEST_PUBLIC_IP],
):
yield
async def test_default_scope_applied_when_doc_has_no_scope(
self, httpx_mock, mock_dns
):
"""When the CIMD document omits scope, the manager's default_scope is used."""
url = "https://example.com/client.json"
doc_data = {
"client_id": url,
"client_name": "Test App",
"redirect_uris": ["http://localhost:3000/callback"],
"token_endpoint_auth_method": "none",
# No scope field
}
httpx_mock.add_response(
json=doc_data,
headers={"content-length": "200"},
)
manager = CIMDClientManager(
enable_cimd=True,
default_scope="read write admin",
)
client = await manager.get_client(url)
assert client is not None
assert client.scope == "read write admin"
async def test_doc_scope_takes_precedence_over_default(self, httpx_mock, mock_dns):
"""When the CIMD document specifies scope, it wins over the default."""
url = "https://example.com/client.json"
doc_data = {
"client_id": url,
"client_name": "Test App",
"redirect_uris": ["http://localhost:3000/callback"],
"token_endpoint_auth_method": "none",
"scope": "custom-scope",
}
httpx_mock.add_response(
json=doc_data,
headers={"content-length": "200"},
)
manager = CIMDClientManager(
enable_cimd=True,
default_scope="default-scope",
)
client = await manager.get_client(url)
assert client is not None
assert client.scope == "custom-scope"
async def test_allowed_redirect_uri_patterns_stored_on_client(
self, httpx_mock, mock_dns
):
"""Proxy's allowed_redirect_uri_patterns are forwarded to the created client."""
url = "https://example.com/client.json"
doc_data = {
"client_id": url,
"client_name": "Test App",
"redirect_uris": ["http://localhost:*/callback"],
"token_endpoint_auth_method": "none",
}
httpx_mock.add_response(
json=doc_data,
headers={"content-length": "200"},
)
patterns = ["http://localhost:*", "https://app.example.com/*"]
manager = CIMDClientManager(
enable_cimd=True,
allowed_redirect_uri_patterns=patterns,
)
client = await manager.get_client(url)
assert client is not None
assert client.allowed_redirect_uri_patterns == patterns
async def test_cimd_document_attached_to_client(self, httpx_mock, mock_dns):
"""The fetched CIMDDocument is attached to the created client."""
url = "https://example.com/client.json"
doc_data = {
"client_id": url,
"client_name": "Attached Doc App",
"redirect_uris": ["http://localhost:3000/callback"],
"token_endpoint_auth_method": "none",
}
httpx_mock.add_response(
json=doc_data,
headers={"content-length": "200"},
)
manager = CIMDClientManager(enable_cimd=True)
client = await manager.get_client(url)
assert client is not None
assert client.cimd_document is not None
assert client.cimd_document.client_name == "Attached Doc App"
assert str(client.cimd_document.client_id) == url
class TestCIMDClientManagerValidatePrivateKeyJwt:
"""Tests for CIMDClientManager.validate_private_key_jwt wrapper."""
@pytest.fixture
def manager(self):
return CIMDClientManager(enable_cimd=True)
async def test_missing_cimd_document_raises(self, manager):
"""validate_private_key_jwt raises ValueError if client has no cimd_document."""
client = ProxyDCRClient(
client_id="https://example.com/client.json",
client_secret=None,
redirect_uris=None,
cimd_document=None,
)
with pytest.raises(ValueError, match="must have CIMD document"):
await manager.validate_private_key_jwt(
"fake.jwt.token",
client,
"https://oauth.example.com/token",
)
async def test_wrong_auth_method_raises(self, manager):
"""validate_private_key_jwt raises ValueError if auth method is not private_key_jwt."""
cimd_doc = CIMDDocument(
client_id=AnyHttpUrl("https://example.com/client.json"),
redirect_uris=["http://localhost:3000/callback"],
token_endpoint_auth_method="none", # Not private_key_jwt
)
client = ProxyDCRClient(
client_id="https://example.com/client.json",
client_secret=None,
redirect_uris=None,
cimd_document=cimd_doc,
)
with pytest.raises(ValueError, match="private_key_jwt"):
await manager.validate_private_key_jwt(
"fake.jwt.token",
client,
"https://oauth.example.com/token",
)
async def test_success_delegates_to_assertion_validator(self, manager):
"""On success, validate_private_key_jwt delegates to the assertion validator."""
cimd_doc = CIMDDocument(
client_id=AnyHttpUrl("https://example.com/client.json"),
redirect_uris=["http://localhost:3000/callback"],
token_endpoint_auth_method="private_key_jwt",
jwks_uri=AnyHttpUrl("https://example.com/.well-known/jwks.json"),
)
client = ProxyDCRClient(
client_id="https://example.com/client.json",
client_secret=None,
redirect_uris=None,
cimd_document=cimd_doc,
)
manager._assertion_validator.validate_assertion = AsyncMock(return_value=True)
result = await manager.validate_private_key_jwt(
"test.jwt.assertion",
client,
"https://oauth.example.com/token",
)
assert result is True
manager._assertion_validator.validate_assertion.assert_awaited_once_with(
"test.jwt.assertion",
"https://example.com/client.json",
"https://oauth.example.com/token",
cimd_doc,
)
class TestCIMDRedirectUriEnforcement:
"""Tests for CIMD redirect_uri validation security.
Verifies that CIMD clients enforce BOTH:
1. CIMD document's redirect_uris
2. Proxy's allowed_redirect_uri_patterns
"""
@pytest.fixture
def mock_dns(self):
"""Mock DNS resolution to return test public IP."""
with patch(
"fastmcp.server.auth.ssrf.resolve_hostname",
return_value=[TEST_PUBLIC_IP],
):
yield
async def test_cimd_redirect_uris_enforced(self, httpx_mock, mock_dns):
"""Test that CIMD document redirect_uris are enforced.
Even if proxy patterns allow http://localhost:*, a CIMD client
should only accept URIs declared in its document.
"""
from mcp.shared.auth import InvalidRedirectUriError
from pydantic import AnyUrl
url = "https://example.com/client.json"
doc_data = {
"client_id": url,
"client_name": "Test App",
# CIMD only declares port 3000
"redirect_uris": ["http://localhost:3000/callback"],
"token_endpoint_auth_method": "none",
}
httpx_mock.add_response(
json=doc_data,
headers={"content-length": "200"},
)
# Proxy allows any localhost port
manager = CIMDClientManager(
enable_cimd=True,
allowed_redirect_uri_patterns=["http://localhost:*"],
)
client = await manager.get_client(url)
assert client is not None
# Declared URI should work
validated = client.validate_redirect_uri(
AnyUrl("http://localhost:3000/callback")
)
assert str(validated) == "http://localhost:3000/callback"
# Different port should fail (not in CIMD redirect_uris)
with pytest.raises(InvalidRedirectUriError):
client.validate_redirect_uri(AnyUrl("http://localhost:4000/callback"))
async def test_proxy_patterns_also_checked(self, httpx_mock, mock_dns):
"""Test that proxy patterns are checked even for CIMD clients.
A CIMD client should not be able to use a redirect_uri that's
in its document but not allowed by proxy patterns.
"""
from mcp.shared.auth import InvalidRedirectUriError
from pydantic import AnyUrl
url = "https://example.com/client.json"
doc_data = {
"client_id": url,
"client_name": "Test App",
# CIMD declares both localhost and external URI
"redirect_uris": [
"http://localhost:3000/callback",
"https://evil.com/callback",
],
"token_endpoint_auth_method": "none",
}
httpx_mock.add_response(
json=doc_data,
headers={"content-length": "200"},
)
# Proxy only allows localhost
manager = CIMDClientManager(
enable_cimd=True,
allowed_redirect_uri_patterns=["http://localhost:*"],
)
client = await manager.get_client(url)
assert client is not None
# Localhost should work (in CIMD and matches pattern)
validated = client.validate_redirect_uri(
AnyUrl("http://localhost:3000/callback")
)
assert str(validated) == "http://localhost:3000/callback"
# Evil.com should fail (in CIMD but doesn't match proxy patterns)
with pytest.raises(InvalidRedirectUriError):
client.validate_redirect_uri(AnyUrl("https://evil.com/callback"))