mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
* Add Auth0MCPProvider for Auth0 Auth for MCP * Document Auth0 MCP provider integration * Add Auth0MCPProvider scope and auth rejection tests Cover permissions-based required_scopes enforcement and unauthenticated MCP 401 responses. * fixed documentation * Narrow Auth0 docs to integration guide only * Fix Auth0 provider: use httpx2 instead of httpx httpx is a dev-only transitive dependency in this repo; runtime installs declare httpx2 exclusively. The module-level 'import httpx' in auth0.py broke import on a clean install of fastmcp or fastmcp-slim[server]. --------- Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
392 lines
14 KiB
Python
392 lines
14 KiB
Python
"""Tests for Auth0 MCP resource server provider."""
|
|
|
|
from unittest.mock import patch
|
|
|
|
import httpx2
|
|
import pytest
|
|
|
|
from fastmcp import FastMCP
|
|
from fastmcp.server.auth.oidc_proxy import OIDCConfiguration
|
|
from fastmcp.server.auth.providers.auth0 import Auth0JWTVerifier, Auth0MCPProvider
|
|
from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair
|
|
|
|
TEST_CONFIG_URL = "https://example.us.auth0.com/.well-known/openid-configuration"
|
|
TEST_BASE_URL = "http://127.0.0.1:8000"
|
|
TEST_ISSUER = "https://example.us.auth0.com/"
|
|
TEST_JWKS_URI = "https://example.us.auth0.com/.well-known/jwks.json"
|
|
|
|
|
|
@pytest.fixture
|
|
def valid_oidc_configuration_dict():
|
|
return {
|
|
"issuer": TEST_ISSUER,
|
|
"authorization_endpoint": "https://example.us.auth0.com/authorize",
|
|
"token_endpoint": "https://example.us.auth0.com/oauth/token",
|
|
"jwks_uri": TEST_JWKS_URI,
|
|
"registration_endpoint": "https://example.us.auth0.com/oidc/register",
|
|
"response_types_supported": ["code"],
|
|
"subject_types_supported": ["public"],
|
|
"id_token_signing_alg_values_supported": ["RS256"],
|
|
}
|
|
|
|
|
|
class TestAuth0JWTVerifier:
|
|
def test_extract_scopes_includes_permissions(self):
|
|
verifier = Auth0JWTVerifier(
|
|
jwks_uri=TEST_JWKS_URI,
|
|
issuer=TEST_ISSUER,
|
|
)
|
|
scopes = verifier._extract_scopes(
|
|
{"scope": "openid", "permissions": ["tool:whoami", "tool:greet"]}
|
|
)
|
|
assert scopes == ["openid", "tool:whoami", "tool:greet"]
|
|
|
|
def test_extract_scopes_permissions_string(self):
|
|
verifier = Auth0JWTVerifier(
|
|
jwks_uri=TEST_JWKS_URI,
|
|
issuer=TEST_ISSUER,
|
|
)
|
|
scopes = verifier._extract_scopes({"permissions": "tool:whoami tool:greet"})
|
|
assert scopes == ["tool:whoami", "tool:greet"]
|
|
|
|
async def test_verify_token_accepts_permissions_as_required_scopes(self):
|
|
key_pair = RSAKeyPair.generate()
|
|
verifier = Auth0JWTVerifier(
|
|
public_key=key_pair.public_key,
|
|
issuer=TEST_ISSUER,
|
|
required_scopes=["tool:echo"],
|
|
)
|
|
token = key_pair.create_token(
|
|
subject="user_123",
|
|
issuer=TEST_ISSUER,
|
|
additional_claims={"permissions": ["tool:echo"]},
|
|
)
|
|
|
|
access_token = await verifier.load_access_token(token)
|
|
assert access_token is not None
|
|
assert access_token.client_id == "user_123"
|
|
|
|
async def test_verify_token_rejects_missing_permissions(self):
|
|
key_pair = RSAKeyPair.generate()
|
|
verifier = Auth0JWTVerifier(
|
|
public_key=key_pair.public_key,
|
|
issuer=TEST_ISSUER,
|
|
required_scopes=["tool:echo"],
|
|
)
|
|
token = key_pair.create_token(
|
|
subject="user_123",
|
|
issuer=TEST_ISSUER,
|
|
additional_claims={"permissions": ["tool:other"]},
|
|
)
|
|
|
|
access_token = await verifier.load_access_token(token)
|
|
assert access_token is None
|
|
|
|
|
|
class TestAuth0MCPProviderInit:
|
|
def test_init_from_oidc_discovery(self, valid_oidc_configuration_dict):
|
|
with patch(
|
|
"fastmcp.server.auth.providers.auth0.OIDCConfiguration.get_oidc_configuration"
|
|
) as mock_get:
|
|
mock_get.return_value = OIDCConfiguration.model_validate(
|
|
valid_oidc_configuration_dict
|
|
)
|
|
|
|
provider = Auth0MCPProvider(
|
|
config_url=TEST_CONFIG_URL,
|
|
base_url=TEST_BASE_URL,
|
|
)
|
|
|
|
mock_get.assert_called_once()
|
|
assert provider.issuer == "https://example.us.auth0.com"
|
|
assert str(provider.base_url) == f"{TEST_BASE_URL}/"
|
|
verifier = provider.token_verifier
|
|
assert isinstance(verifier, Auth0JWTVerifier)
|
|
assert verifier.jwks_uri == TEST_JWKS_URI
|
|
assert verifier.issuer == TEST_ISSUER
|
|
assert len(provider.authorization_servers) == 1
|
|
assert (
|
|
str(provider.authorization_servers[0]).rstrip("/")
|
|
== "https://example.us.auth0.com"
|
|
)
|
|
|
|
def test_custom_token_verifier_not_replaced(self, valid_oidc_configuration_dict):
|
|
custom = JWTVerifier(
|
|
jwks_uri=TEST_JWKS_URI,
|
|
issuer=TEST_ISSUER,
|
|
audience="https://custom.example.com/mcp",
|
|
)
|
|
with patch(
|
|
"fastmcp.server.auth.providers.auth0.OIDCConfiguration.get_oidc_configuration"
|
|
) as mock_get:
|
|
mock_get.return_value = OIDCConfiguration.model_validate(
|
|
valid_oidc_configuration_dict
|
|
)
|
|
provider = Auth0MCPProvider(
|
|
config_url=TEST_CONFIG_URL,
|
|
base_url=TEST_BASE_URL,
|
|
token_verifier=custom,
|
|
)
|
|
|
|
assert provider.token_verifier is custom
|
|
assert provider._auto_bind_audience is False
|
|
|
|
|
|
class TestAuth0MCPAudienceBinding:
|
|
def test_audience_binds_on_set_mcp_path(self, valid_oidc_configuration_dict):
|
|
with patch(
|
|
"fastmcp.server.auth.providers.auth0.OIDCConfiguration.get_oidc_configuration"
|
|
) as mock_get:
|
|
mock_get.return_value = OIDCConfiguration.model_validate(
|
|
valid_oidc_configuration_dict
|
|
)
|
|
provider = Auth0MCPProvider(
|
|
config_url=TEST_CONFIG_URL,
|
|
base_url=TEST_BASE_URL,
|
|
)
|
|
|
|
verifier = provider.token_verifier
|
|
assert isinstance(verifier, Auth0JWTVerifier)
|
|
assert verifier.audience is None
|
|
|
|
provider.set_mcp_path("/mcp")
|
|
assert verifier.audience == "http://127.0.0.1:8000/mcp"
|
|
|
|
def test_audience_respects_resource_base_url(self, valid_oidc_configuration_dict):
|
|
with patch(
|
|
"fastmcp.server.auth.providers.auth0.OIDCConfiguration.get_oidc_configuration"
|
|
) as mock_get:
|
|
mock_get.return_value = OIDCConfiguration.model_validate(
|
|
valid_oidc_configuration_dict
|
|
)
|
|
provider = Auth0MCPProvider(
|
|
config_url=TEST_CONFIG_URL,
|
|
base_url="https://oauth.example.com",
|
|
resource_base_url="https://api.example.com",
|
|
)
|
|
|
|
provider.set_mcp_path("/mcp")
|
|
verifier = provider.token_verifier
|
|
assert isinstance(verifier, Auth0JWTVerifier)
|
|
assert verifier.audience == "https://api.example.com/mcp"
|
|
|
|
def test_custom_verifier_audience_not_overwritten(
|
|
self, valid_oidc_configuration_dict
|
|
):
|
|
custom_audience = "https://other.example.com"
|
|
custom = JWTVerifier(
|
|
jwks_uri=TEST_JWKS_URI,
|
|
issuer=TEST_ISSUER,
|
|
audience=custom_audience,
|
|
)
|
|
with patch(
|
|
"fastmcp.server.auth.providers.auth0.OIDCConfiguration.get_oidc_configuration"
|
|
) as mock_get:
|
|
mock_get.return_value = OIDCConfiguration.model_validate(
|
|
valid_oidc_configuration_dict
|
|
)
|
|
provider = Auth0MCPProvider(
|
|
config_url=TEST_CONFIG_URL,
|
|
base_url=TEST_BASE_URL,
|
|
token_verifier=custom,
|
|
)
|
|
provider.set_mcp_path("/mcp")
|
|
|
|
assert custom.audience == custom_audience
|
|
|
|
def test_set_mcp_path_none_binds_to_base_url(self, valid_oidc_configuration_dict):
|
|
with patch(
|
|
"fastmcp.server.auth.providers.auth0.OIDCConfiguration.get_oidc_configuration"
|
|
) as mock_get:
|
|
mock_get.return_value = OIDCConfiguration.model_validate(
|
|
valid_oidc_configuration_dict
|
|
)
|
|
provider = Auth0MCPProvider(
|
|
config_url=TEST_CONFIG_URL,
|
|
base_url=TEST_BASE_URL,
|
|
)
|
|
|
|
provider.set_mcp_path(None)
|
|
|
|
verifier = provider.token_verifier
|
|
assert isinstance(verifier, Auth0JWTVerifier)
|
|
assert verifier.audience == "http://127.0.0.1:8000/"
|
|
|
|
def test_audience_binds_through_http_app(self, valid_oidc_configuration_dict):
|
|
with patch(
|
|
"fastmcp.server.auth.providers.auth0.OIDCConfiguration.get_oidc_configuration"
|
|
) as mock_get:
|
|
mock_get.return_value = OIDCConfiguration.model_validate(
|
|
valid_oidc_configuration_dict
|
|
)
|
|
auth = Auth0MCPProvider(
|
|
config_url=TEST_CONFIG_URL,
|
|
base_url=TEST_BASE_URL,
|
|
)
|
|
mcp = FastMCP("test", auth=auth)
|
|
mcp.http_app(path="/mcp")
|
|
|
|
verifier = auth.token_verifier
|
|
assert isinstance(verifier, Auth0JWTVerifier)
|
|
assert verifier.audience == "http://127.0.0.1:8000/mcp"
|
|
|
|
|
|
class TestAuth0MCPMetadataForwarding:
|
|
async def test_forwards_authorization_server_metadata(
|
|
self, valid_oidc_configuration_dict, monkeypatch
|
|
):
|
|
metadata_payload = {
|
|
"issuer": TEST_ISSUER,
|
|
"authorization_endpoint": "https://example.us.auth0.com/authorize",
|
|
"token_endpoint": "https://example.us.auth0.com/oauth/token",
|
|
"registration_endpoint": "https://example.us.auth0.com/oidc/register",
|
|
}
|
|
|
|
class DummyResponse:
|
|
def __init__(self, payload):
|
|
self._payload = payload
|
|
|
|
def raise_for_status(self):
|
|
return None
|
|
|
|
def json(self):
|
|
return self._payload
|
|
|
|
class DummyAsyncClient:
|
|
last_url: str | None = None
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
pass
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *args):
|
|
return None
|
|
|
|
async def get(self, url):
|
|
DummyAsyncClient.last_url = url
|
|
return DummyResponse(metadata_payload)
|
|
|
|
real_httpx_client = httpx2.AsyncClient
|
|
|
|
monkeypatch.setattr(
|
|
"fastmcp.server.auth.providers.auth0.httpx2.AsyncClient",
|
|
DummyAsyncClient,
|
|
)
|
|
|
|
with patch(
|
|
"fastmcp.server.auth.providers.auth0.OIDCConfiguration.get_oidc_configuration"
|
|
) as mock_get:
|
|
mock_get.return_value = OIDCConfiguration.model_validate(
|
|
valid_oidc_configuration_dict
|
|
)
|
|
provider = Auth0MCPProvider(
|
|
config_url=TEST_CONFIG_URL,
|
|
base_url=TEST_BASE_URL,
|
|
)
|
|
|
|
mcp = FastMCP("test", auth=provider)
|
|
app = mcp.http_app()
|
|
|
|
async with real_httpx_client(
|
|
transport=httpx2.ASGITransport(app=app),
|
|
base_url=TEST_BASE_URL,
|
|
) as client:
|
|
response = await client.get("/.well-known/oauth-authorization-server")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == metadata_payload
|
|
assert (
|
|
DummyAsyncClient.last_url
|
|
== "https://example.us.auth0.com/.well-known/oauth-authorization-server"
|
|
)
|
|
|
|
|
|
class TestAuth0MCPIntegration:
|
|
async def test_unauthenticated_mcp_request_returns_401(
|
|
self, valid_oidc_configuration_dict
|
|
):
|
|
with patch(
|
|
"fastmcp.server.auth.providers.auth0.OIDCConfiguration.get_oidc_configuration"
|
|
) as mock_get:
|
|
mock_get.return_value = OIDCConfiguration.model_validate(
|
|
valid_oidc_configuration_dict
|
|
)
|
|
provider = Auth0MCPProvider(
|
|
config_url=TEST_CONFIG_URL,
|
|
base_url=TEST_BASE_URL,
|
|
)
|
|
|
|
mcp = FastMCP("test-server", auth=provider)
|
|
|
|
@mcp.tool
|
|
def echo(message: str) -> str:
|
|
return message
|
|
|
|
app = mcp.http_app()
|
|
|
|
async with httpx2.AsyncClient(
|
|
transport=httpx2.ASGITransport(app=app),
|
|
base_url=TEST_BASE_URL,
|
|
) as client:
|
|
response = await client.post(
|
|
"/mcp",
|
|
json={"jsonrpc": "2.0", "method": "tools/list", "id": 1},
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
|
|
assert response.status_code == 401
|
|
|
|
async def test_no_register_proxy_route(self, valid_oidc_configuration_dict):
|
|
with patch(
|
|
"fastmcp.server.auth.providers.auth0.OIDCConfiguration.get_oidc_configuration"
|
|
) as mock_get:
|
|
mock_get.return_value = OIDCConfiguration.model_validate(
|
|
valid_oidc_configuration_dict
|
|
)
|
|
provider = Auth0MCPProvider(
|
|
config_url=TEST_CONFIG_URL,
|
|
base_url=TEST_BASE_URL,
|
|
)
|
|
|
|
mcp = FastMCP("test-server", auth=provider)
|
|
app = mcp.http_app()
|
|
|
|
async with httpx2.AsyncClient(
|
|
transport=httpx2.ASGITransport(app=app),
|
|
base_url=TEST_BASE_URL,
|
|
) as client:
|
|
response = await client.post(
|
|
"/register",
|
|
json={"client_name": "Test", "redirect_uris": ["http://localhost/cb"]},
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
|
|
assert response.status_code == 404
|
|
|
|
async def test_protected_resource_metadata(self, valid_oidc_configuration_dict):
|
|
with patch(
|
|
"fastmcp.server.auth.providers.auth0.OIDCConfiguration.get_oidc_configuration"
|
|
) as mock_get:
|
|
mock_get.return_value = OIDCConfiguration.model_validate(
|
|
valid_oidc_configuration_dict
|
|
)
|
|
provider = Auth0MCPProvider(
|
|
config_url=TEST_CONFIG_URL,
|
|
base_url=TEST_BASE_URL,
|
|
)
|
|
|
|
mcp = FastMCP("test-server", auth=provider)
|
|
app = mcp.http_app()
|
|
|
|
async with httpx2.AsyncClient(
|
|
transport=httpx2.ASGITransport(app=app),
|
|
base_url=TEST_BASE_URL,
|
|
) as client:
|
|
response = await client.get("/.well-known/oauth-protected-resource/mcp")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["resource"] == f"{TEST_BASE_URL}/mcp"
|
|
assert data["authorization_servers"] == [TEST_ISSUER]
|