fastmcp/tests/server/auth/test_oauth_proxy_storage.py
Jeremiah Lowin 686082a5b5
Add platform-aware OAuth token persistence (#2218)
* Add comprehensive keyring integration tests

Prevents OS keyring pollution during testing by adding a global mock in
conftest.py. Tests verify keyring behavior across platforms and fallback
scenarios without writing to the actual system keyring.

- Add global mock_keyring fixture to tests/conftest.py
- Add TestOAuthProxyKeyring class with 6 keyring-specific tests
- Remove try/except ImportError for keyring (now required dependency)
- Add keyring extra to py-key-value-aio dependency
- Clean up extraneous implementation comments in oauth_proxy.py

* Update OAuth keyring documentation

Update all OAuth-related documentation to reflect keyring-based key management:
- Add version badges to jwt_signing_key, token_encryption_key, and client_storage parameters
- Standardize "Default behavior (`None`):" formatting with backticks
- Ensure consistent messaging about development-only defaults across all docs
- Update oauth-proxy.mdx, oidc-proxy.mdx, http.mdx, storage-backends.mdx, and upgrade-guide.mdx
2025-10-22 20:42:24 -04:00

433 lines
17 KiB
Python

"""Tests for OAuth proxy with persistent storage."""
import platform
from collections.abc import AsyncGenerator
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
import pytest
from diskcache.core import tempfile
from inline_snapshot import snapshot
from key_value.aio.stores.disk import DiskStore, MultiDiskStore
from key_value.aio.stores.memory import MemoryStore
from mcp.shared.auth import OAuthClientInformationFull
from pydantic import AnyUrl
from fastmcp.server.auth.oauth_proxy import OAuthProxy
class TestOAuthProxyStorage:
"""Tests for OAuth proxy client storage functionality."""
@pytest.fixture
def jwt_verifier(self):
"""Create a mock JWT verifier."""
verifier = Mock()
verifier.required_scopes = ["read", "write"]
verifier.verify_token = AsyncMock(return_value=None)
return verifier
@pytest.fixture
async def temp_storage(self) -> AsyncGenerator[MultiDiskStore, None]:
"""Create file-based storage for testing."""
with tempfile.TemporaryDirectory() as temp_dir:
disk_store = MultiDiskStore(base_directory=Path(temp_dir))
yield disk_store
await disk_store.close()
@pytest.fixture
def memory_storage(self) -> MemoryStore:
"""Create in-memory storage for testing."""
return MemoryStore()
def create_proxy(self, jwt_verifier, storage=None) -> OAuthProxy:
"""Create an OAuth proxy with specified storage."""
return OAuthProxy(
upstream_authorization_endpoint="https://github.com/login/oauth/authorize",
upstream_token_endpoint="https://github.com/login/oauth/access_token",
upstream_client_id="test-client-id",
upstream_client_secret="test-client-secret",
token_verifier=jwt_verifier,
base_url="https://myserver.com",
redirect_path="/auth/callback",
client_storage=storage,
)
async def test_default_storage_is_platform_appropriate(self, jwt_verifier):
"""Test that proxy defaults to appropriate storage for platform."""
proxy = self.create_proxy(jwt_verifier, storage=None)
if platform.system() == "Linux":
# Linux: no keyring support, use MemoryStore
assert isinstance(proxy._client_storage, MemoryStore)
else:
# Mac/Windows: keyring available, use DiskStore
assert isinstance(proxy._client_storage, DiskStore)
async def test_register_and_get_client(self, jwt_verifier, temp_storage):
"""Test registering and retrieving a client."""
proxy = self.create_proxy(jwt_verifier, storage=temp_storage)
# Register client
client_info = OAuthClientInformationFull(
client_id="test-client-123",
client_secret="secret-456",
redirect_uris=[AnyUrl("http://localhost:8080/callback")],
grant_types=["authorization_code", "refresh_token"],
scope="read write",
)
await proxy.register_client(client_info)
# Get client back
client = await proxy.get_client("test-client-123")
assert client is not None
assert client.client_id == "test-client-123"
assert client.client_secret == "secret-456"
assert client.scope == "read write"
async def test_client_persists_across_proxy_instances(
self, jwt_verifier, temp_storage
):
"""Test that clients persist when proxy is recreated."""
# First proxy registers client
proxy1 = self.create_proxy(jwt_verifier, storage=temp_storage)
client_info = OAuthClientInformationFull(
client_id="persistent-client",
client_secret="persistent-secret",
redirect_uris=[AnyUrl("http://localhost:9999/callback")],
scope="openid profile",
)
await proxy1.register_client(client_info)
# Second proxy can retrieve it
proxy2 = self.create_proxy(jwt_verifier, storage=temp_storage)
client = await proxy2.get_client("persistent-client")
assert client is not None
assert client.client_secret == "persistent-secret"
assert client.scope == "openid profile"
async def test_nonexistent_client_returns_none(self, jwt_verifier, temp_storage):
"""Test that requesting non-existent client returns None."""
proxy = self.create_proxy(jwt_verifier, storage=temp_storage)
client = await proxy.get_client("does-not-exist")
assert client is None
async def test_proxy_dcr_client_redirect_validation(
self, jwt_verifier, temp_storage
):
"""Test that ProxyDCRClient is created with redirect URI patterns."""
proxy = OAuthProxy(
upstream_authorization_endpoint="https://github.com/login/oauth/authorize",
upstream_token_endpoint="https://github.com/login/oauth/access_token",
upstream_client_id="test-client-id",
upstream_client_secret="test-client-secret",
token_verifier=jwt_verifier,
base_url="https://myserver.com",
allowed_client_redirect_uris=["http://localhost:*"],
client_storage=temp_storage,
)
client_info = OAuthClientInformationFull(
client_id="test-proxy-client",
client_secret="secret",
redirect_uris=[AnyUrl("http://localhost:8080/callback")],
)
await proxy.register_client(client_info)
# Get client back - should be ProxyDCRClient
client = await proxy.get_client("test-proxy-client")
assert client is not None
# ProxyDCRClient should validate dynamic localhost ports
validated = client.validate_redirect_uri(
AnyUrl("http://localhost:12345/callback")
)
assert validated is not None
async def test_in_memory_storage_option(self, jwt_verifier):
"""Test using in-memory storage explicitly."""
storage = MemoryStore()
proxy = self.create_proxy(jwt_verifier, storage=storage)
client_info = OAuthClientInformationFull(
client_id="memory-client",
client_secret="memory-secret",
redirect_uris=[AnyUrl("http://localhost:8080/callback")],
)
await proxy.register_client(client_info)
client = await proxy.get_client("memory-client")
assert client is not None
# Create new proxy with same storage instance
proxy2 = self.create_proxy(jwt_verifier, storage=storage)
client2 = await proxy2.get_client("memory-client")
assert client2 is not None
# But new storage instance won't have it
proxy3 = self.create_proxy(jwt_verifier, storage=MemoryStore())
client3 = await proxy3.get_client("memory-client")
assert client3 is None
async def test_storage_data_structure(self, jwt_verifier, temp_storage):
"""Test that storage uses proper structured format."""
proxy = self.create_proxy(jwt_verifier, storage=temp_storage)
client_info = OAuthClientInformationFull(
client_id="structured-client",
client_secret="secret",
redirect_uris=[AnyUrl("http://localhost:8080/callback")],
)
await proxy.register_client(client_info)
# Check raw storage data
raw_data = await temp_storage.get(
collection="mcp-oauth-proxy-clients", key="structured-client"
)
assert raw_data is not None
assert raw_data == snapshot(
{
"redirect_uris": ["http://localhost:8080/callback"],
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scope": "read write",
"client_name": None,
"client_uri": None,
"logo_uri": None,
"contacts": None,
"tos_uri": None,
"policy_uri": None,
"jwks_uri": None,
"jwks": None,
"software_id": None,
"software_version": None,
"client_id": "structured-client",
"client_secret": "secret",
"client_id_issued_at": None,
"client_secret_expires_at": None,
"allowed_redirect_uri_patterns": None,
}
)
class TestOAuthProxyKeyring:
"""Tests for OAuth proxy keyring integration.
All tests mock keyring to prevent pollution of the OS keyring during testing.
"""
@pytest.fixture
def jwt_verifier(self):
"""Create a mock JWT verifier."""
verifier = Mock()
verifier.required_scopes = ["read", "write"]
verifier.verify_token = AsyncMock(return_value=None)
return verifier
@pytest.fixture
def memory_storage(self) -> MemoryStore:
"""Create in-memory storage for testing."""
return MemoryStore()
@patch("fastmcp.utilities.key_management.platform.system")
@patch("fastmcp.utilities.key_management.keyring")
async def test_keyring_used_on_mac_windows(
self, mock_keyring, mock_platform, jwt_verifier, memory_storage
):
"""Test that keyring is used on Mac/Windows platforms."""
# Simulate Mac platform
mock_platform.return_value = "Darwin"
# Mock keyring to return None (first time, no existing key)
mock_keyring.get_password.return_value = None
# Create proxy without explicit keys (should use keyring)
proxy = OAuthProxy(
upstream_authorization_endpoint="https://github.com/login/oauth/authorize",
upstream_token_endpoint="https://github.com/login/oauth/access_token",
upstream_client_id="test-keyring-client",
upstream_client_secret="test-secret",
token_verifier=jwt_verifier,
base_url="https://myserver.com",
client_storage=memory_storage,
)
# Trigger JWT initialization to activate keyring calls
await proxy._ensure_jwt_initialized()
# Verify keyring was accessed for both JWT and encryption keys
assert mock_keyring.get_password.call_count == 2
assert mock_keyring.set_password.call_count == 2
# Verify service name and key names
jwt_calls = [
call
for call in mock_keyring.get_password.call_args_list
if "jwt-signing" in str(call)
]
encryption_calls = [
call
for call in mock_keyring.get_password.call_args_list
if "token-encryption" in str(call)
]
assert len(jwt_calls) == 1
assert len(encryption_calls) == 1
# Check that keys were stored with correct service name
set_calls = mock_keyring.set_password.call_args_list
for call in set_calls:
assert call[0][0] == "fastmcp" # service name
assert "test-keyring-client" in call[0][1] # namespace in key name
@patch("fastmcp.utilities.key_management.platform.system")
@patch("fastmcp.utilities.key_management.keyring")
async def test_keyring_skipped_on_linux(
self, mock_keyring, mock_platform, jwt_verifier, memory_storage
):
"""Test that keyring is skipped on Linux platforms."""
# Simulate Linux platform
mock_platform.return_value = "Linux"
# Create proxy without explicit keys
OAuthProxy(
upstream_authorization_endpoint="https://github.com/login/oauth/authorize",
upstream_token_endpoint="https://github.com/login/oauth/access_token",
upstream_client_id="linux-client",
upstream_client_secret="test-secret",
token_verifier=jwt_verifier,
base_url="https://myserver.com",
client_storage=memory_storage,
)
# Keyring should never be accessed on Linux
mock_keyring.get_password.assert_not_called()
mock_keyring.set_password.assert_not_called()
@patch("fastmcp.utilities.key_management.platform.system")
@patch("fastmcp.utilities.key_management.keyring")
async def test_explicit_keys_bypass_keyring(
self, mock_keyring, mock_platform, jwt_verifier, memory_storage
):
"""Test that explicit keys bypass keyring entirely."""
mock_platform.return_value = "Darwin"
# Create proxy with explicit keys
OAuthProxy(
upstream_authorization_endpoint="https://github.com/login/oauth/authorize",
upstream_token_endpoint="https://github.com/login/oauth/access_token",
upstream_client_id="explicit-keys-client",
upstream_client_secret="test-secret",
token_verifier=jwt_verifier,
base_url="https://myserver.com",
jwt_signing_key="my-custom-jwt-key",
token_encryption_key="my-custom-encryption-key",
client_storage=memory_storage,
)
# Keyring should never be accessed when explicit keys provided
mock_keyring.get_password.assert_not_called()
mock_keyring.set_password.assert_not_called()
@patch("fastmcp.utilities.key_management.platform.system")
@patch("fastmcp.utilities.key_management.keyring")
async def test_keyring_namespace_isolation(
self, mock_keyring, mock_platform, jwt_verifier, memory_storage
):
"""Test that different upstream client IDs create isolated keyring entries."""
mock_platform.return_value = "Darwin"
mock_keyring.get_password.return_value = None
# Create first proxy with client-A
OAuthProxy(
upstream_authorization_endpoint="https://github.com/login/oauth/authorize",
upstream_token_endpoint="https://github.com/login/oauth/access_token",
upstream_client_id="client-A",
upstream_client_secret="secret-A",
token_verifier=jwt_verifier,
base_url="https://myserver.com",
client_storage=memory_storage,
)
# Reset mock to track second proxy separately
mock_keyring.reset_mock()
mock_keyring.get_password.return_value = None
# Create second proxy with client-B
OAuthProxy(
upstream_authorization_endpoint="https://github.com/login/oauth/authorize",
upstream_token_endpoint="https://github.com/login/oauth/access_token",
upstream_client_id="client-B",
upstream_client_secret="secret-B",
token_verifier=jwt_verifier,
base_url="https://myserver.com",
client_storage=MemoryStore(), # Different storage instance
)
# Verify that client-B keys were stored with different namespace
set_calls = mock_keyring.set_password.call_args_list
for call in set_calls:
assert call[0][0] == "fastmcp"
assert "client-B" in call[0][1] # Namespace includes client-B
assert "client-A" not in call[0][1] # Not client-A
@patch("fastmcp.utilities.key_management.platform.system")
@patch("fastmcp.utilities.key_management.keyring")
async def test_keyring_retrieves_existing_keys(
self, mock_keyring, mock_platform, jwt_verifier, memory_storage
):
"""Test that existing keyring keys are retrieved and reused."""
mock_platform.return_value = "Darwin"
# Mock existing keys in keyring
def get_password_side_effect(service, key):
if "jwt-signing" in key:
return "existing-jwt-key-base64"
elif "token-encryption" in key:
return "existing-encryption-key-base64"
return None
mock_keyring.get_password.side_effect = get_password_side_effect
# Create proxy - should retrieve existing keys
proxy = OAuthProxy(
upstream_authorization_endpoint="https://github.com/login/oauth/authorize",
upstream_token_endpoint="https://github.com/login/oauth/access_token",
upstream_client_id="existing-keys-client",
upstream_client_secret="test-secret",
token_verifier=jwt_verifier,
base_url="https://myserver.com",
client_storage=memory_storage,
)
# Trigger JWT initialization
await proxy._ensure_jwt_initialized()
# Should retrieve but not set new keys
assert mock_keyring.get_password.call_count == 2
mock_keyring.set_password.assert_not_called()
@patch("fastmcp.utilities.key_management.platform.system")
@patch("fastmcp.utilities.key_management.keyring")
async def test_keyring_failure_uses_ephemeral_keys(
self, mock_keyring, mock_platform, jwt_verifier, memory_storage
):
"""Test graceful fallback to ephemeral keys when keyring fails."""
mock_platform.return_value = "Darwin"
# Simulate keyring failure
mock_keyring.get_password.side_effect = Exception("Keyring backend unavailable")
# Should not raise - should fall back to ephemeral keys
proxy = OAuthProxy(
upstream_authorization_endpoint="https://github.com/login/oauth/authorize",
upstream_token_endpoint="https://github.com/login/oauth/access_token",
upstream_client_id="fallback-client",
upstream_client_secret="test-secret",
token_verifier=jwt_verifier,
base_url="https://myserver.com",
client_storage=memory_storage,
)
# Proxy should be created successfully despite keyring failure
assert proxy is not None