60e0ffc959
Upgrade checks / Notify on failure (push) Has been cancelled
Upgrade checks / Close issue on success (push) Has been cancelled
Schema Crash Test / Real-world schema crash test (232K schemas) (push) Has been cancelled
Run static analysis / static_analysis (push) Has been cancelled
Tests / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Tests / Tests with lowest-direct dependencies (push) Has been cancelled
Tests / MCP conformance tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
Tests / Package install smoke (push) Has been cancelled
Upgrade checks / Static analysis (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Upgrade checks / Integration tests (push) Has been cancelled
Update MCPServerConfig Schema / update-config-schema (push) Has been cancelled
Update SDK Documentation / update-sdk-docs (push) Has been cancelled
368 lines
12 KiB
Python
368 lines
12 KiB
Python
"""Tests for http_client parameter on token verifiers.
|
|
|
|
Verifies that all token verifiers accept an optional httpx.AsyncClient for
|
|
connection pooling (issues #3287 and #3293).
|
|
"""
|
|
|
|
import time
|
|
|
|
import httpx
|
|
import pytest
|
|
from joserfc import jwk
|
|
from pytest_httpx import HTTPXMock
|
|
|
|
from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier
|
|
from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair
|
|
|
|
|
|
class TestIntrospectionHttpClient:
|
|
"""Test http_client parameter on IntrospectionTokenVerifier."""
|
|
|
|
@pytest.fixture
|
|
def shared_client(self) -> httpx.AsyncClient:
|
|
return httpx.AsyncClient(timeout=30)
|
|
|
|
def test_stores_http_client(self, shared_client: httpx.AsyncClient):
|
|
verifier = IntrospectionTokenVerifier(
|
|
introspection_url="https://auth.example.com/introspect",
|
|
client_id="test",
|
|
client_secret="secret",
|
|
http_client=shared_client,
|
|
)
|
|
assert verifier._http_client is shared_client
|
|
|
|
def test_default_http_client_is_none(self):
|
|
verifier = IntrospectionTokenVerifier(
|
|
introspection_url="https://auth.example.com/introspect",
|
|
client_id="test",
|
|
client_secret="secret",
|
|
)
|
|
assert verifier._http_client is None
|
|
|
|
async def test_uses_provided_client(
|
|
self, shared_client: httpx.AsyncClient, httpx_mock: HTTPXMock
|
|
):
|
|
"""When http_client is provided, it should be used for requests."""
|
|
httpx_mock.add_response(
|
|
url="https://auth.example.com/introspect",
|
|
method="POST",
|
|
json={
|
|
"active": True,
|
|
"client_id": "user-1",
|
|
"scope": "read",
|
|
"exp": int(time.time()) + 3600,
|
|
},
|
|
)
|
|
|
|
verifier = IntrospectionTokenVerifier(
|
|
introspection_url="https://auth.example.com/introspect",
|
|
client_id="test",
|
|
client_secret="secret",
|
|
http_client=shared_client,
|
|
)
|
|
|
|
result = await verifier.verify_token("tok")
|
|
assert result is not None
|
|
assert result.client_id == "user-1"
|
|
|
|
async def test_client_not_closed_after_call(
|
|
self, shared_client: httpx.AsyncClient, httpx_mock: HTTPXMock
|
|
):
|
|
"""User-provided client must not be closed by the verifier."""
|
|
httpx_mock.add_response(
|
|
url="https://auth.example.com/introspect",
|
|
method="POST",
|
|
json={
|
|
"active": True,
|
|
"client_id": "user-1",
|
|
"scope": "read",
|
|
"exp": int(time.time()) + 3600,
|
|
},
|
|
)
|
|
|
|
verifier = IntrospectionTokenVerifier(
|
|
introspection_url="https://auth.example.com/introspect",
|
|
client_id="test",
|
|
client_secret="secret",
|
|
http_client=shared_client,
|
|
)
|
|
|
|
await verifier.verify_token("tok")
|
|
# Client should still be open — not closed by the verifier
|
|
assert not shared_client.is_closed
|
|
|
|
async def test_reuses_client_across_calls(
|
|
self, shared_client: httpx.AsyncClient, httpx_mock: HTTPXMock
|
|
):
|
|
"""Same client instance should be reused across multiple verify_token calls."""
|
|
for _ in range(3):
|
|
httpx_mock.add_response(
|
|
url="https://auth.example.com/introspect",
|
|
method="POST",
|
|
json={
|
|
"active": True,
|
|
"client_id": "user-1",
|
|
"scope": "read",
|
|
"exp": int(time.time()) + 3600,
|
|
},
|
|
)
|
|
|
|
verifier = IntrospectionTokenVerifier(
|
|
introspection_url="https://auth.example.com/introspect",
|
|
client_id="test",
|
|
client_secret="secret",
|
|
http_client=shared_client,
|
|
)
|
|
|
|
for _ in range(3):
|
|
result = await verifier.verify_token("tok")
|
|
assert result is not None
|
|
|
|
assert not shared_client.is_closed
|
|
|
|
|
|
class TestJWTVerifierHttpClient:
|
|
"""Test http_client parameter on JWTVerifier."""
|
|
|
|
@pytest.fixture(scope="class")
|
|
def rsa_key_pair(self) -> RSAKeyPair:
|
|
return RSAKeyPair.generate()
|
|
|
|
@pytest.fixture
|
|
def shared_client(self) -> httpx.AsyncClient:
|
|
return httpx.AsyncClient(timeout=30)
|
|
|
|
def test_stores_http_client(self, shared_client: httpx.AsyncClient):
|
|
verifier = JWTVerifier(
|
|
jwks_uri="https://auth.example.com/.well-known/jwks.json",
|
|
http_client=shared_client,
|
|
)
|
|
assert verifier._http_client is shared_client
|
|
|
|
def test_default_http_client_is_none(self):
|
|
verifier = JWTVerifier(
|
|
jwks_uri="https://auth.example.com/.well-known/jwks.json",
|
|
)
|
|
assert verifier._http_client is None
|
|
|
|
async def test_jwks_fetch_uses_provided_client(
|
|
self,
|
|
rsa_key_pair: RSAKeyPair,
|
|
shared_client: httpx.AsyncClient,
|
|
httpx_mock: HTTPXMock,
|
|
):
|
|
"""When http_client is provided, JWKS fetches should use it."""
|
|
# Build a JWKS response from the RSA key pair
|
|
public_key_obj = jwk.import_key(rsa_key_pair.public_key, "RSA")
|
|
jwk_dict = dict(public_key_obj.as_dict())
|
|
jwk_dict["kid"] = "test-key-1"
|
|
jwk_dict["use"] = "sig"
|
|
jwk_dict["alg"] = "RS256"
|
|
|
|
httpx_mock.add_response(
|
|
url="https://auth.example.com/.well-known/jwks.json",
|
|
json={"keys": [jwk_dict]},
|
|
)
|
|
|
|
verifier = JWTVerifier(
|
|
jwks_uri="https://auth.example.com/.well-known/jwks.json",
|
|
issuer="https://auth.example.com",
|
|
http_client=shared_client,
|
|
)
|
|
|
|
token = rsa_key_pair.create_token(
|
|
issuer="https://auth.example.com",
|
|
kid="test-key-1",
|
|
)
|
|
|
|
result = await verifier.verify_token(token)
|
|
assert result is not None
|
|
assert not shared_client.is_closed
|
|
|
|
def test_ssrf_safe_rejects_http_client_with_jwks(
|
|
self,
|
|
shared_client: httpx.AsyncClient,
|
|
):
|
|
"""ssrf_safe=True and http_client cannot be used together with JWKS."""
|
|
with pytest.raises(ValueError, match="cannot be used with ssrf_safe=True"):
|
|
JWTVerifier(
|
|
jwks_uri="https://auth.example.com/.well-known/jwks.json",
|
|
ssrf_safe=True,
|
|
http_client=shared_client,
|
|
)
|
|
|
|
def test_ssrf_safe_allows_http_client_with_static_key(
|
|
self,
|
|
rsa_key_pair: RSAKeyPair,
|
|
shared_client: httpx.AsyncClient,
|
|
):
|
|
"""ssrf_safe with http_client is allowed when using static public_key (no HTTP)."""
|
|
# This should NOT raise — static key means no JWKS fetching
|
|
verifier = JWTVerifier(
|
|
public_key=rsa_key_pair.public_key,
|
|
ssrf_safe=True,
|
|
http_client=shared_client,
|
|
)
|
|
assert verifier._http_client is shared_client
|
|
assert verifier.ssrf_safe is True
|
|
|
|
|
|
class TestGitHubHttpClient:
|
|
"""Test http_client parameter on GitHubTokenVerifier."""
|
|
|
|
def test_stores_http_client(self):
|
|
from fastmcp.server.auth.providers.github import GitHubTokenVerifier
|
|
|
|
client = httpx.AsyncClient()
|
|
verifier = GitHubTokenVerifier(http_client=client)
|
|
assert verifier._http_client is client
|
|
|
|
async def test_uses_provided_client(self, httpx_mock: HTTPXMock):
|
|
from fastmcp.server.auth.providers.github import GitHubTokenVerifier
|
|
|
|
client = httpx.AsyncClient()
|
|
httpx_mock.add_response(
|
|
url="https://api.github.com/user",
|
|
json={"id": 123, "login": "testuser"},
|
|
)
|
|
httpx_mock.add_response(
|
|
url="https://api.github.com/user/repos",
|
|
headers={"x-oauth-scopes": "user,repo"},
|
|
json=[],
|
|
)
|
|
|
|
verifier = GitHubTokenVerifier(http_client=client)
|
|
result = await verifier.verify_token("ghp_test")
|
|
assert result is not None
|
|
assert not client.is_closed
|
|
|
|
|
|
class TestDiscordHttpClient:
|
|
"""Test http_client parameter on DiscordTokenVerifier."""
|
|
|
|
def test_stores_http_client(self):
|
|
from fastmcp.server.auth.providers.discord import DiscordTokenVerifier
|
|
|
|
client = httpx.AsyncClient()
|
|
verifier = DiscordTokenVerifier(
|
|
expected_client_id="test-client-id",
|
|
http_client=client,
|
|
)
|
|
assert verifier._http_client is client
|
|
|
|
|
|
class TestGoogleHttpClient:
|
|
"""Test http_client parameter on GoogleTokenVerifier."""
|
|
|
|
def test_stores_http_client(self):
|
|
from fastmcp.server.auth.providers.google import GoogleTokenVerifier
|
|
|
|
client = httpx.AsyncClient()
|
|
verifier = GoogleTokenVerifier(http_client=client)
|
|
assert verifier._http_client is client
|
|
|
|
|
|
class TestWorkOSHttpClient:
|
|
"""Test http_client parameter on WorkOSTokenVerifier."""
|
|
|
|
def test_stores_http_client(self):
|
|
from fastmcp.server.auth.providers.workos import WorkOSTokenVerifier
|
|
|
|
client = httpx.AsyncClient()
|
|
verifier = WorkOSTokenVerifier(
|
|
authkit_domain="https://test.authkit.app",
|
|
http_client=client,
|
|
)
|
|
assert verifier._http_client is client
|
|
|
|
|
|
class TestProviderHttpClientPassthrough:
|
|
"""Test that convenience providers pass http_client to their verifiers."""
|
|
|
|
def test_github_provider_threads_http_client(self):
|
|
from fastmcp.server.auth.providers.github import (
|
|
GitHubProvider,
|
|
GitHubTokenVerifier,
|
|
)
|
|
|
|
client = httpx.AsyncClient()
|
|
provider = GitHubProvider(
|
|
client_id="test",
|
|
client_secret="secret",
|
|
base_url="https://example.com",
|
|
http_client=client,
|
|
)
|
|
# OAuthProxy stores token verifier as _token_validator
|
|
verifier = provider._token_validator
|
|
assert isinstance(verifier, GitHubTokenVerifier)
|
|
assert verifier._http_client is client
|
|
|
|
def test_discord_provider_threads_http_client(self):
|
|
from fastmcp.server.auth.providers.discord import (
|
|
DiscordProvider,
|
|
DiscordTokenVerifier,
|
|
)
|
|
|
|
client = httpx.AsyncClient()
|
|
provider = DiscordProvider(
|
|
client_id="test",
|
|
client_secret="secret",
|
|
base_url="https://example.com",
|
|
http_client=client,
|
|
)
|
|
verifier = provider._token_validator
|
|
assert isinstance(verifier, DiscordTokenVerifier)
|
|
assert verifier._http_client is client
|
|
|
|
def test_google_provider_threads_http_client(self):
|
|
from fastmcp.server.auth.providers.google import (
|
|
GoogleProvider,
|
|
GoogleTokenVerifier,
|
|
)
|
|
|
|
client = httpx.AsyncClient()
|
|
provider = GoogleProvider(
|
|
client_id="test",
|
|
client_secret="secret",
|
|
base_url="https://example.com",
|
|
http_client=client,
|
|
)
|
|
verifier = provider._token_validator
|
|
assert isinstance(verifier, GoogleTokenVerifier)
|
|
assert verifier._http_client is client
|
|
|
|
def test_workos_provider_threads_http_client(self):
|
|
from fastmcp.server.auth.providers.workos import (
|
|
WorkOSProvider,
|
|
WorkOSTokenVerifier,
|
|
)
|
|
|
|
client = httpx.AsyncClient()
|
|
provider = WorkOSProvider(
|
|
client_id="test",
|
|
client_secret="secret",
|
|
authkit_domain="https://test.authkit.app",
|
|
base_url="https://example.com",
|
|
http_client=client,
|
|
)
|
|
verifier = provider._token_validator
|
|
assert isinstance(verifier, WorkOSTokenVerifier)
|
|
assert verifier._http_client is client
|
|
|
|
def test_azure_provider_threads_http_client(self):
|
|
from fastmcp.server.auth.providers.azure import AzureProvider
|
|
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
|
|
|
client = httpx.AsyncClient()
|
|
provider = AzureProvider(
|
|
client_id="test-client-id",
|
|
client_secret="secret",
|
|
tenant_id="test-tenant-id",
|
|
required_scopes=["read"],
|
|
base_url="https://example.com",
|
|
http_client=client,
|
|
)
|
|
verifier = provider._token_validator
|
|
assert isinstance(verifier, JWTVerifier)
|
|
assert verifier._http_client is client
|