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
201 lines
7.7 KiB
Python
201 lines
7.7 KiB
Python
"""Tests for OAuth proxy authorization flow."""
|
|
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
import pytest
|
|
from key_value.aio.stores.memory import MemoryStore
|
|
from mcp.server.auth.provider import AuthorizationParams
|
|
from mcp.shared.auth import OAuthClientInformationFull
|
|
from pydantic import AnyUrl
|
|
|
|
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
|
|
|
|
|
class TestOAuthProxyAuthorization:
|
|
"""Tests for OAuth proxy authorization flow."""
|
|
|
|
async def test_authorize_creates_transaction(self, oauth_proxy):
|
|
"""Test that authorize creates transaction and redirects to consent."""
|
|
client = OAuthClientInformationFull(
|
|
client_id="test-client",
|
|
client_secret="test-secret",
|
|
redirect_uris=[AnyUrl("http://localhost:54321/callback")],
|
|
jwt_signing_key="test-secret", # type: ignore[call-arg] # Optional field in MCP SDK # ty:ignore[unknown-argument]
|
|
)
|
|
|
|
# Register client first (required for consent flow)
|
|
await oauth_proxy.register_client(client)
|
|
|
|
params = AuthorizationParams(
|
|
redirect_uri=AnyUrl("http://localhost:54321/callback"),
|
|
redirect_uri_provided_explicitly=True,
|
|
state="client-state-123",
|
|
code_challenge="challenge-abc",
|
|
scopes=["read", "write"],
|
|
)
|
|
|
|
redirect_url = await oauth_proxy.authorize(client, params)
|
|
|
|
# Parse the redirect URL
|
|
parsed = urlparse(redirect_url)
|
|
query_params = parse_qs(parsed.query)
|
|
|
|
# Should redirect to consent page
|
|
assert "/consent" in redirect_url
|
|
assert "txn_id" in query_params
|
|
|
|
# Verify transaction was stored with correct data
|
|
txn_id = query_params["txn_id"][0]
|
|
transaction = await oauth_proxy._transaction_store.get(key=txn_id)
|
|
assert transaction is not None
|
|
assert transaction.client_id == "test-client"
|
|
assert transaction.code_challenge == "challenge-abc"
|
|
assert transaction.client_state == "client-state-123"
|
|
assert transaction.scopes == ["read", "write"]
|
|
|
|
|
|
class TestOAuthProxyPKCE:
|
|
"""Tests for OAuth proxy PKCE forwarding."""
|
|
|
|
@pytest.fixture
|
|
def proxy_with_pkce(self, jwt_verifier):
|
|
return OAuthProxy(
|
|
upstream_authorization_endpoint="https://oauth.example.com/authorize",
|
|
upstream_token_endpoint="https://oauth.example.com/token",
|
|
upstream_client_id="upstream-client",
|
|
upstream_client_secret="upstream-secret",
|
|
token_verifier=jwt_verifier,
|
|
base_url="https://proxy.example.com",
|
|
forward_pkce=True,
|
|
jwt_signing_key="test-secret",
|
|
client_storage=MemoryStore(),
|
|
)
|
|
|
|
@pytest.fixture
|
|
def proxy_without_pkce(self, jwt_verifier):
|
|
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
|
|
|
return OAuthProxy(
|
|
upstream_authorization_endpoint="https://oauth.example.com/authorize",
|
|
upstream_token_endpoint="https://oauth.example.com/token",
|
|
upstream_client_id="upstream-client",
|
|
upstream_client_secret="upstream-secret",
|
|
token_verifier=jwt_verifier,
|
|
base_url="https://proxy.example.com",
|
|
forward_pkce=False,
|
|
jwt_signing_key="test-secret",
|
|
client_storage=MemoryStore(),
|
|
)
|
|
|
|
async def test_pkce_forwarding_enabled(self, proxy_with_pkce):
|
|
"""Test that proxy generates and forwards its own PKCE."""
|
|
client = OAuthClientInformationFull(
|
|
client_id="test-client",
|
|
client_secret="test-secret",
|
|
redirect_uris=[AnyUrl("http://localhost:12345/callback")],
|
|
)
|
|
|
|
# Register client first
|
|
await proxy_with_pkce.register_client(client)
|
|
|
|
params = AuthorizationParams(
|
|
redirect_uri=AnyUrl("http://localhost:12345/callback"),
|
|
redirect_uri_provided_explicitly=True,
|
|
state="client-state",
|
|
code_challenge="client_challenge",
|
|
scopes=["read"],
|
|
)
|
|
|
|
redirect_url = await proxy_with_pkce.authorize(client, params)
|
|
query_params = parse_qs(urlparse(redirect_url).query)
|
|
|
|
# Should redirect to consent page
|
|
assert "/consent" in redirect_url
|
|
assert "txn_id" in query_params
|
|
|
|
# Transaction should store both challenges
|
|
txn_id = query_params["txn_id"][0]
|
|
transaction = await proxy_with_pkce._transaction_store.get(key=txn_id)
|
|
assert transaction is not None
|
|
assert transaction.code_challenge == "client_challenge" # Client's
|
|
assert transaction.proxy_code_verifier is not None # Proxy's verifier
|
|
# Proxy code challenge is computed from verifier when building upstream URL
|
|
# Just verify the verifier exists and is different from client's challenge
|
|
assert len(transaction.proxy_code_verifier) > 0
|
|
|
|
async def test_pkce_forwarding_disabled(self, proxy_without_pkce):
|
|
"""Test that PKCE is not forwarded when disabled."""
|
|
client = OAuthClientInformationFull(
|
|
client_id="test-client",
|
|
client_secret="test-secret",
|
|
redirect_uris=[AnyUrl("http://localhost:12345/callback")],
|
|
)
|
|
|
|
# Register client first
|
|
await proxy_without_pkce.register_client(client)
|
|
|
|
params = AuthorizationParams(
|
|
redirect_uri=AnyUrl("http://localhost:12345/callback"),
|
|
redirect_uri_provided_explicitly=True,
|
|
state="client-state",
|
|
code_challenge="client_challenge",
|
|
scopes=["read"],
|
|
)
|
|
|
|
redirect_url = await proxy_without_pkce.authorize(client, params)
|
|
query_params = parse_qs(urlparse(redirect_url).query)
|
|
|
|
# Should redirect to consent page
|
|
assert "/consent" in redirect_url
|
|
assert "txn_id" in query_params
|
|
|
|
# Client's challenge still stored, but no proxy PKCE
|
|
txn_id = query_params["txn_id"][0]
|
|
transaction = await proxy_without_pkce._transaction_store.get(key=txn_id)
|
|
assert transaction is not None
|
|
assert transaction.code_challenge == "client_challenge"
|
|
assert transaction.proxy_code_verifier is None # No proxy PKCE when disabled
|
|
|
|
|
|
class TestParameterForwarding:
|
|
"""Tests for parameter forwarding in OAuth proxy."""
|
|
|
|
async def test_extra_authorize_params_forwarded(self, jwt_verifier):
|
|
"""Test that extra authorize parameters are forwarded to upstream."""
|
|
proxy = OAuthProxy(
|
|
upstream_authorization_endpoint="https://oauth.example.com/authorize",
|
|
upstream_token_endpoint="https://oauth.example.com/token",
|
|
upstream_client_id="upstream-client",
|
|
upstream_client_secret="upstream-secret",
|
|
token_verifier=jwt_verifier,
|
|
base_url="https://proxy.example.com",
|
|
jwt_signing_key="test-secret",
|
|
extra_authorize_params={
|
|
"audience": "https://api.example.com",
|
|
"prompt": "consent",
|
|
"max_age": "3600",
|
|
},
|
|
client_storage=MemoryStore(),
|
|
)
|
|
|
|
client = OAuthClientInformationFull(
|
|
client_id="test-client",
|
|
client_secret="test-secret",
|
|
redirect_uris=[AnyUrl("http://localhost:12345/callback")],
|
|
)
|
|
|
|
await proxy.register_client(client)
|
|
|
|
params = AuthorizationParams(
|
|
redirect_uri=AnyUrl("http://localhost:12345/callback"),
|
|
redirect_uri_provided_explicitly=True,
|
|
state="client-state",
|
|
code_challenge="challenge",
|
|
scopes=["read"],
|
|
# No resource parameter
|
|
)
|
|
|
|
# Should succeed (no resource check needed)
|
|
redirect_url = await proxy.authorize(client, params)
|
|
assert "/consent" in redirect_url
|