fastmcp/tests/server/auth/providers/test_descope.py
Kevin J Gao 05db547d86
feat: Made Changes to DescopeProvider to Support New Well Known URLs (#2392)
* added new DescopeProvider for Agentic Identity Hub

* fixed formatting with ruff

* made coderabbit change

* changed wording in descope.mdx

* added backwards compatibility

* ran ruff

* added scopes_supported to protected metadata resource

* empty commit to re-run tests
2025-11-22 12:20:50 -05:00

264 lines
11 KiB
Python

"""Tests for Descope OAuth provider."""
import os
from unittest.mock import patch
import httpx
import pytest
from fastmcp import Client, FastMCP
from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.server.auth.providers.descope import DescopeProvider
from fastmcp.utilities.tests import HeadlessOAuth, run_server_async
class TestDescopeProvider:
"""Test Descope OAuth provider functionality."""
def test_init_with_explicit_params(self):
"""Test DescopeProvider initialization with explicit parameters."""
provider = DescopeProvider(
config_url="https://api.descope.com/v1/apps/agentic/P2abc123/M123/.well-known/openid-configuration",
base_url="https://myserver.com",
)
assert provider.project_id == "P2abc123"
assert str(provider.base_url) == "https://myserver.com/"
assert str(provider.descope_base_url) == "https://api.descope.com"
def test_init_with_env_vars(self):
"""Test DescopeProvider initialization from environment variables."""
with patch.dict(
os.environ,
{
"FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_CONFIG_URL": "https://api.descope.com/v1/apps/agentic/P2env123/M123/.well-known/openid-configuration",
"FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_BASE_URL": "https://envserver.com",
},
):
provider = DescopeProvider()
assert provider.project_id == "P2env123"
assert str(provider.base_url) == "https://envserver.com/"
assert str(provider.descope_base_url) == "https://api.descope.com"
def test_init_with_old_env_vars(self):
"""Test DescopeProvider initialization from old environment variables (backwards compatibility)."""
with patch.dict(
os.environ,
{
"FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_PROJECT_ID": "P2oldenv123",
"FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_DESCOPE_BASE_URL": "https://api.descope.com",
"FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_BASE_URL": "https://envserver.com",
},
):
provider = DescopeProvider()
assert provider.project_id == "P2oldenv123"
assert str(provider.base_url) == "https://envserver.com/"
assert str(provider.descope_base_url) == "https://api.descope.com"
assert (
provider.token_verifier.issuer # type: ignore[attr-defined]
== "https://api.descope.com/v1/apps/P2oldenv123"
)
def test_environment_variable_loading(self):
"""Test that environment variables are loaded correctly."""
# This test verifies that the provider can be created with environment variables
provider = DescopeProvider(
config_url="https://api.descope.com/v1/apps/agentic/P2env123/M123/.well-known/openid-configuration",
base_url="http://env-server.com",
)
# Should have loaded from environment
assert provider.project_id == "P2env123"
assert str(provider.base_url) == "http://env-server.com/"
assert str(provider.descope_base_url) == "https://api.descope.com"
def test_config_url_parsing(self):
"""Test that config_url is parsed correctly to extract base URL and project ID."""
# Standard HTTPS URL
provider1 = DescopeProvider(
config_url="https://api.descope.com/v1/apps/agentic/P2abc123/M123/.well-known/openid-configuration",
base_url="https://myserver.com",
)
assert str(provider1.descope_base_url) == "https://api.descope.com"
assert provider1.project_id == "P2abc123"
# HTTP URL (for local testing)
provider2 = DescopeProvider(
config_url="http://localhost:8080/v1/apps/agentic/P2abc123/M123/.well-known/openid-configuration",
base_url="https://myserver.com",
)
assert str(provider2.descope_base_url) == "http://localhost:8080"
assert provider2.project_id == "P2abc123"
# URL without .well-known/openid-configuration suffix
provider3 = DescopeProvider(
config_url="https://api.descope.com/v1/apps/agentic/P2abc123/M123",
base_url="https://myserver.com",
)
assert str(provider3.descope_base_url) == "https://api.descope.com"
assert provider3.project_id == "P2abc123"
def test_requires_config_url_or_project_id_and_descope_base_url(self):
"""Test that either config_url or both project_id and descope_base_url are required."""
# Should raise error when neither API is provided
with pytest.raises(ValueError, match="Either config_url"):
DescopeProvider(
base_url="https://myserver.com",
)
def test_backwards_compatibility_with_project_id_and_descope_base_url(self):
"""Test backwards compatibility with old API using project_id and descope_base_url."""
provider = DescopeProvider(
project_id="P2abc123",
descope_base_url="https://api.descope.com",
base_url="https://myserver.com",
)
assert provider.project_id == "P2abc123"
assert str(provider.descope_base_url) == "https://api.descope.com"
assert str(provider.base_url) == "https://myserver.com/"
# Check that JWT verifier uses the old issuer format
assert (
provider.token_verifier.issuer # type: ignore[attr-defined]
== "https://api.descope.com/v1/apps/P2abc123"
)
assert (
provider.token_verifier.jwks_uri # type: ignore[attr-defined]
== "https://api.descope.com/P2abc123/.well-known/jwks.json"
)
def test_backwards_compatibility_descope_base_url_without_scheme(self):
"""Test that descope_base_url without scheme gets https:// prefix added."""
provider = DescopeProvider(
project_id="P2abc123",
descope_base_url="api.descope.com",
base_url="https://myserver.com",
)
assert str(provider.descope_base_url) == "https://api.descope.com"
assert (
provider.token_verifier.issuer # type: ignore[attr-defined]
== "https://api.descope.com/v1/apps/P2abc123"
)
def test_config_url_takes_precedence_over_old_api(self):
"""Test that config_url takes precedence when both APIs are provided."""
provider = DescopeProvider(
config_url="https://api.descope.com/v1/apps/agentic/P2new123/M123/.well-known/openid-configuration",
project_id="P2old123", # Should be ignored
descope_base_url="https://old.descope.com", # Should be ignored
base_url="https://myserver.com",
)
# Should use values from config_url, not the old API
assert provider.project_id == "P2new123"
assert str(provider.descope_base_url) == "https://api.descope.com"
assert (
provider.token_verifier.issuer # type: ignore[attr-defined]
== "https://api.descope.com/v1/apps/agentic/P2new123/M123"
)
def test_jwt_verifier_configured_correctly(self):
"""Test that JWT verifier is configured correctly."""
config_url = "https://api.descope.com/v1/apps/agentic/P2abc123/M123/.well-known/openid-configuration"
issuer_url = "https://api.descope.com/v1/apps/agentic/P2abc123/M123"
provider = DescopeProvider(
config_url=config_url,
base_url="https://myserver.com",
)
# Check that JWT verifier uses the correct endpoints
assert (
provider.token_verifier.jwks_uri # type: ignore[attr-defined]
== "https://api.descope.com/P2abc123/.well-known/jwks.json"
)
assert (
provider.token_verifier.issuer == issuer_url # type: ignore[attr-defined]
)
assert provider.token_verifier.audience == "P2abc123" # type: ignore[attr-defined]
def test_required_scopes_support(self):
"""Test that required_scopes are supported and passed to JWT verifier."""
provider = DescopeProvider(
config_url="https://api.descope.com/v1/apps/agentic/P2abc123/M123/.well-known/openid-configuration",
base_url="https://myserver.com",
required_scopes=["read", "write"],
)
# Check that required_scopes are set on the token verifier
assert provider.token_verifier.required_scopes == ["read", "write"] # type: ignore[attr-defined]
def test_required_scopes_with_old_api(self):
"""Test that required_scopes work with the old API (project_id + descope_base_url)."""
provider = DescopeProvider(
project_id="P2abc123",
descope_base_url="https://api.descope.com",
base_url="https://myserver.com",
required_scopes=["openid", "email"],
)
# Check that required_scopes are set on the token verifier
assert provider.token_verifier.required_scopes == ["openid", "email"] # type: ignore[attr-defined]
def test_required_scopes_from_env(self):
"""Test that required_scopes can be set via environment variable."""
with patch.dict(
os.environ,
{
"FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_CONFIG_URL": "https://api.descope.com/v1/apps/agentic/P2env123/M123/.well-known/openid-configuration",
"FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_BASE_URL": "https://envserver.com",
"FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_REQUIRED_SCOPES": "read,write",
},
):
provider = DescopeProvider()
assert provider.token_verifier.required_scopes == ["read", "write"] # type: ignore[attr-defined]
@pytest.fixture
async def mcp_server_url():
"""Start Descope server."""
mcp = FastMCP(
auth=DescopeProvider(
config_url="https://api.descope.com/v1/apps/agentic/P2test123/M123/.well-known/openid-configuration",
base_url="http://localhost:4321",
)
)
@mcp.tool
def add(a: int, b: int) -> int:
return a + b
async with run_server_async(mcp, transport="http") as url:
yield url
@pytest.fixture
def client_with_headless_oauth(mcp_server_url: str) -> Client:
"""Client with headless OAuth that bypasses browser interaction."""
return Client(
transport=StreamableHttpTransport(mcp_server_url),
auth=HeadlessOAuth(mcp_url=mcp_server_url),
)
class TestDescopeProviderIntegration:
async def test_unauthorized_access(self, mcp_server_url: str):
with pytest.raises(httpx.HTTPStatusError) as exc_info:
async with Client(mcp_server_url) as client:
tools = await client.list_tools() # noqa: F841
assert isinstance(exc_info.value, httpx.HTTPStatusError)
assert exc_info.value.response.status_code == 401
assert "tools" not in locals()
# async def test_authorized_access(self, client_with_headless_oauth: Client):
# async with client_with_headless_oauth:
# tools = await client_with_headless_oauth.list_tools()
# assert tools is not None
# assert len(tools) > 0
# assert "add" in tools