fastmcp/tests/server/auth/providers/test_azure.py
Jeremiah Lowin 0ad638003c Rename OAuth providers to include DCR suffix
Renames all OAuth providers that inherit from OAuthDCRProxy to explicitly
include "DCR" in their names, clarifying their Dynamic Client Registration
implementation approach.

Changes:
- GitHubProvider → GitHubDCRProvider
- GoogleProvider → GoogleDCRProvider
- AzureProvider → AzureDCRProvider
- WorkOSProvider → WorkOSDCRProvider
- Auth0Provider → Auth0DCRProvider
- AWSCognitoProvider → AWSCognitoDCRProvider

All old names remain as deprecated aliases with warnings that respect
settings.deprecation_warnings. Environment variables updated to include
_DCR_ with backwards compatibility via env_prefixes.
2025-10-20 17:32:35 -04:00

303 lines
12 KiB
Python

"""Tests for Azure (Microsoft Entra) OAuth provider."""
import os
from unittest.mock import patch
from urllib.parse import parse_qs, urlparse
import pytest
from mcp.server.auth.provider import AuthorizationParams
from mcp.shared.auth import OAuthClientInformationFull
from pydantic import AnyUrl
from fastmcp.server.auth.providers.azure import AzureDCRProvider
from fastmcp.server.auth.providers.jwt import JWTVerifier
class TestAzureDCRProvider:
"""Test Azure OAuth provider functionality."""
def test_init_with_explicit_params(self):
"""Test AzureDCRProvider initialization with explicit parameters."""
provider = AzureDCRProvider(
client_id="12345678-1234-1234-1234-123456789012",
client_secret="azure_secret_123",
tenant_id="87654321-4321-4321-4321-210987654321",
base_url="https://myserver.com",
required_scopes=["User.Read", "Mail.Read"],
)
assert provider._upstream_client_id == "12345678-1234-1234-1234-123456789012"
assert provider._upstream_client_secret.get_secret_value() == "azure_secret_123"
assert str(provider.base_url) == "https://myserver.com/"
# Check tenant is in the endpoints
parsed_auth = urlparse(provider._upstream_authorization_endpoint)
assert "87654321-4321-4321-4321-210987654321" in parsed_auth.path
parsed_token = urlparse(provider._upstream_token_endpoint)
assert "87654321-4321-4321-4321-210987654321" in parsed_token.path
@pytest.mark.parametrize(
"scopes_env",
[
"User.Read,Calendar.Read",
'["User.Read", "Calendar.Read"]',
],
)
def test_init_with_env_vars(self, scopes_env):
"""Test AzureDCRProvider initialization from environment variables."""
with patch.dict(
os.environ,
{
"FASTMCP_SERVER_AUTH_AZURE_DCR_CLIENT_ID": "env-client-id",
"FASTMCP_SERVER_AUTH_AZURE_DCR_CLIENT_SECRET": "env-secret",
"FASTMCP_SERVER_AUTH_AZURE_DCR_TENANT_ID": "env-tenant-id",
"FASTMCP_SERVER_AUTH_AZURE_DCR_BASE_URL": "https://envserver.com",
"FASTMCP_SERVER_AUTH_AZURE_DCR_REQUIRED_SCOPES": scopes_env,
},
):
provider = AzureDCRProvider()
assert provider._upstream_client_id == "env-client-id"
assert provider._upstream_client_secret.get_secret_value() == "env-secret"
assert str(provider.base_url) == "https://envserver.com/"
assert provider._token_validator.required_scopes == [
"User.Read",
"Calendar.Read",
]
# Check tenant is in the endpoints
parsed_auth = urlparse(provider._upstream_authorization_endpoint)
assert "env-tenant-id" in parsed_auth.path
parsed_token = urlparse(provider._upstream_token_endpoint)
assert "env-tenant-id" in parsed_token.path
def test_init_missing_client_id_raises_error(self):
"""Test that missing client_id raises ValueError."""
with pytest.raises(ValueError, match="client_id is required"):
AzureDCRProvider(
client_secret="test_secret",
tenant_id="test-tenant",
)
def test_init_missing_client_secret_raises_error(self):
"""Test that missing client_secret raises ValueError."""
with pytest.raises(ValueError, match="client_secret is required"):
AzureDCRProvider(
client_id="test_client",
tenant_id="test-tenant",
)
def test_init_missing_tenant_id_raises_error(self):
"""Test that missing tenant_id raises ValueError."""
with pytest.raises(ValueError, match="tenant_id is required"):
AzureDCRProvider(
client_id="test_client",
client_secret="test_secret",
)
def test_init_defaults(self):
"""Test that default values are applied correctly."""
provider = AzureDCRProvider(
client_id="test_client",
client_secret="test_secret",
tenant_id="test-tenant",
required_scopes=["User.Read"],
)
# Check defaults
assert provider.base_url is None
assert provider._redirect_path == "/auth/callback"
# Azure provider defaults are set but we can't easily verify them without accessing internals
def test_oauth_endpoints_configured_correctly(self):
"""Test that OAuth endpoints are configured correctly."""
provider = AzureDCRProvider(
client_id="test_client",
client_secret="test_secret",
tenant_id="my-tenant-id",
base_url="https://myserver.com",
required_scopes=["User.Read"],
)
# Check that endpoints use the correct Azure OAuth2 v2.0 endpoints with tenant
assert (
provider._upstream_authorization_endpoint
== "https://login.microsoftonline.com/my-tenant-id/oauth2/v2.0/authorize"
)
assert (
provider._upstream_token_endpoint
== "https://login.microsoftonline.com/my-tenant-id/oauth2/v2.0/token"
)
assert (
provider._upstream_revocation_endpoint is None
) # Azure doesn't support revocation
def test_special_tenant_values(self):
"""Test that special tenant values are accepted."""
# Test with "organizations"
provider1 = AzureDCRProvider(
client_id="test_client",
client_secret="test_secret",
tenant_id="organizations",
required_scopes=["User.Read"],
)
parsed = urlparse(provider1._upstream_authorization_endpoint)
assert "/organizations/" in parsed.path
# Test with "consumers"
provider2 = AzureDCRProvider(
client_id="test_client",
client_secret="test_secret",
tenant_id="consumers",
required_scopes=["User.Read"],
)
parsed = urlparse(provider2._upstream_authorization_endpoint)
assert "/consumers/" in parsed.path
def test_azure_specific_scopes(self):
"""Test handling of Azure-specific scope formats."""
# Just test that the provider accepts Azure-specific scopes without error
provider = AzureDCRProvider(
client_id="test_client",
client_secret="test_secret",
tenant_id="test-tenant",
required_scopes=[
"User.Read",
"Mail.Read",
"Calendar.ReadWrite",
"openid",
"profile",
],
)
# Provider should initialize successfully with these scopes
assert provider is not None
def test_init_does_not_require_api_client_id_anymore(self):
"""API client ID is no longer required; audience is client_id."""
provider = AzureDCRProvider(
client_id="test_client",
client_secret="test_secret",
tenant_id="test-tenant",
required_scopes=["User.Read"],
)
assert provider is not None
def test_init_with_custom_audience_uses_jwt_verifier(self):
"""When audience is provided, JWTVerifier is configured with JWKS and issuer."""
provider = AzureDCRProvider(
client_id="test_client",
client_secret="test_secret",
tenant_id="my-tenant",
identifier_uri="api://my-api",
required_scopes=[".default"],
)
assert provider._token_validator is not None
assert isinstance(provider._token_validator, JWTVerifier)
verifier = provider._token_validator
assert verifier.jwks_uri is not None
assert verifier.jwks_uri.startswith(
"https://login.microsoftonline.com/my-tenant/discovery/v2.0/keys"
)
assert verifier.issuer == "https://login.microsoftonline.com/my-tenant/v2.0"
assert verifier.audience == "test_client"
@pytest.mark.asyncio
async def test_authorize_filters_resource_and_prefixes_scopes_with_audience(self):
"""authorize() should drop resource and prefix non-openid scopes with audience."""
provider = AzureDCRProvider(
client_id="test_client",
client_secret="test_secret",
tenant_id="common",
identifier_uri="api://my-api",
required_scopes=["read", "write"],
base_url="https://srv.example",
)
await provider.register_client(
OAuthClientInformationFull(
client_id="dummy",
client_secret="secret",
redirect_uris=[AnyUrl("http://localhost:12345/callback")],
)
)
client = OAuthClientInformationFull(
client_id="dummy",
client_secret="secret",
redirect_uris=[AnyUrl("http://localhost:12345/callback")],
)
params = AuthorizationParams(
redirect_uri=AnyUrl("http://localhost:12345/callback"),
redirect_uri_provided_explicitly=True,
scopes=["read", "profile"],
state="abc",
code_challenge="xyz",
resource="https://should.be.ignored",
)
url = await provider.authorize(client, params)
# Extract transaction ID from consent redirect
parsed = urlparse(url)
qs = parse_qs(parsed.query)
assert "txn_id" in qs, "Should redirect to consent page with transaction ID"
txn_id = qs["txn_id"][0]
# Verify transaction contains correct parameters (resource filtered, scopes prefixed)
transaction = await provider._transaction_store.get(key=txn_id)
assert transaction is not None
assert "api://my-api/read" in transaction.scopes
assert "api://my-api/profile" in transaction.scopes
# Azure provider filters resource parameter (not stored in transaction)
assert transaction.resource is None
@pytest.mark.asyncio
async def test_authorize_appends_unprefixed_additional_scopes(self):
"""authorize() should append additional_authorize_scopes without prefixing them."""
provider = AzureDCRProvider(
client_id="test_client",
client_secret="test_secret",
tenant_id="common",
identifier_uri="api://my-api",
required_scopes=["read"],
base_url="https://srv.example",
additional_authorize_scopes=["Mail.Read", "User.Read"],
)
await provider.register_client(
OAuthClientInformationFull(
client_id="dummy",
client_secret="secret",
redirect_uris=[AnyUrl("http://localhost:12345/callback")],
)
)
client = OAuthClientInformationFull(
client_id="dummy",
client_secret="secret",
redirect_uris=[AnyUrl("http://localhost:12345/callback")],
)
params = AuthorizationParams(
redirect_uri=AnyUrl("http://localhost:12345/callback"),
redirect_uri_provided_explicitly=True,
scopes=["read"],
state="abc",
code_challenge="xyz",
)
url = await provider.authorize(client, params)
# Extract transaction ID from consent redirect
parsed = urlparse(url)
qs = parse_qs(parsed.query)
assert "txn_id" in qs, "Should redirect to consent page with transaction ID"
txn_id = qs["txn_id"][0]
# Verify transaction contains correct scopes (prefixed + unprefixed additional)
transaction = await provider._transaction_store.get(key=txn_id)
assert transaction is not None
assert "api://my-api/read" in transaction.scopes
assert "Mail.Read" in transaction.scopes
assert "User.Read" in transaction.scopes