Spaces:
Running
Running
Merge branch 'protocol-update' of https://github.com/jlowin/fastmcp into protocol-update
Browse files- src/fastmcp/server/auth/providers/bearer.py +15 -0
- src/fastmcp/server/auth/providers/in_memory.py +15 -0
- src/fastmcp/server/http.py +1 -1
- tests/auth/providers/test_token_verifier.py +179 -0
- tests/server/http/test_auth_setup.py +187 -0
- tests/server/http/test_bearer_auth_backend.py +178 -0
- uv.lock +7 -3
src/fastmcp/server/auth/providers/bearer.py
CHANGED
|
@@ -385,6 +385,21 @@ class BearerAuthProvider(OAuthProvider):
|
|
| 385 |
return scope_claim
|
| 386 |
return []
|
| 387 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 388 |
# --- Unused OAuth server methods ---
|
| 389 |
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
| 390 |
raise NotImplementedError("Client management not supported")
|
|
|
|
| 385 |
return scope_claim
|
| 386 |
return []
|
| 387 |
|
| 388 |
+
async def verify_token(self, token: str) -> AccessToken | None:
|
| 389 |
+
"""
|
| 390 |
+
Verify a bearer token and return access info if valid.
|
| 391 |
+
|
| 392 |
+
This method implements the TokenVerifier protocol by delegating
|
| 393 |
+
to our existing load_access_token method.
|
| 394 |
+
|
| 395 |
+
Args:
|
| 396 |
+
token: The JWT token string to validate
|
| 397 |
+
|
| 398 |
+
Returns:
|
| 399 |
+
AccessToken object if valid, None if invalid or expired
|
| 400 |
+
"""
|
| 401 |
+
return await self.load_access_token(token)
|
| 402 |
+
|
| 403 |
# --- Unused OAuth server methods ---
|
| 404 |
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
| 405 |
raise NotImplementedError("Client management not supported")
|
src/fastmcp/server/auth/providers/in_memory.py
CHANGED
|
@@ -271,6 +271,21 @@ class InMemoryOAuthProvider(OAuthProvider):
|
|
| 271 |
return token_obj
|
| 272 |
return None
|
| 273 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
def _revoke_internal(
|
| 275 |
self, access_token_str: str | None = None, refresh_token_str: str | None = None
|
| 276 |
):
|
|
|
|
| 271 |
return token_obj
|
| 272 |
return None
|
| 273 |
|
| 274 |
+
async def verify_token(self, token: str) -> AccessToken | None:
|
| 275 |
+
"""
|
| 276 |
+
Verify a bearer token and return access info if valid.
|
| 277 |
+
|
| 278 |
+
This method implements the TokenVerifier protocol by delegating
|
| 279 |
+
to our existing load_access_token method.
|
| 280 |
+
|
| 281 |
+
Args:
|
| 282 |
+
token: The token string to validate
|
| 283 |
+
|
| 284 |
+
Returns:
|
| 285 |
+
AccessToken object if valid, None if invalid or expired
|
| 286 |
+
"""
|
| 287 |
+
return await self.load_access_token(token)
|
| 288 |
+
|
| 289 |
def _revoke_internal(
|
| 290 |
self, access_token_str: str | None = None, refresh_token_str: str | None = None
|
| 291 |
):
|
src/fastmcp/server/http.py
CHANGED
|
@@ -87,7 +87,7 @@ def setup_auth_middleware_and_routes(
|
|
| 87 |
middleware = [
|
| 88 |
Middleware(
|
| 89 |
AuthenticationMiddleware,
|
| 90 |
-
backend=BearerAuthBackend(
|
| 91 |
),
|
| 92 |
Middleware(AuthContextMiddleware),
|
| 93 |
]
|
|
|
|
| 87 |
middleware = [
|
| 88 |
Middleware(
|
| 89 |
AuthenticationMiddleware,
|
| 90 |
+
backend=BearerAuthBackend(auth),
|
| 91 |
),
|
| 92 |
Middleware(AuthContextMiddleware),
|
| 93 |
]
|
tests/auth/providers/test_token_verifier.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for TokenVerifier protocol implementation in auth providers."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from mcp.server.auth.provider import AccessToken
|
| 5 |
+
|
| 6 |
+
from fastmcp.server.auth.providers.bearer import BearerAuthProvider, RSAKeyPair
|
| 7 |
+
from fastmcp.server.auth.providers.in_memory import InMemoryOAuthProvider
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class TestBearerAuthProviderTokenVerifier:
|
| 11 |
+
"""Test that BearerAuthProvider implements TokenVerifier protocol correctly."""
|
| 12 |
+
|
| 13 |
+
@pytest.fixture
|
| 14 |
+
def rsa_key_pair(self) -> RSAKeyPair:
|
| 15 |
+
"""Generate RSA key pair for testing."""
|
| 16 |
+
return RSAKeyPair.generate()
|
| 17 |
+
|
| 18 |
+
@pytest.fixture
|
| 19 |
+
def bearer_provider(self, rsa_key_pair: RSAKeyPair) -> BearerAuthProvider:
|
| 20 |
+
"""Create BearerAuthProvider for testing."""
|
| 21 |
+
return BearerAuthProvider(
|
| 22 |
+
public_key=rsa_key_pair.public_key,
|
| 23 |
+
issuer="https://test.example.com",
|
| 24 |
+
audience="https://api.example.com",
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
@pytest.fixture
|
| 28 |
+
def valid_token(self, rsa_key_pair: RSAKeyPair) -> str:
|
| 29 |
+
"""Create a valid test token."""
|
| 30 |
+
return rsa_key_pair.create_token(
|
| 31 |
+
subject="test-user",
|
| 32 |
+
issuer="https://test.example.com",
|
| 33 |
+
audience="https://api.example.com",
|
| 34 |
+
scopes=["read", "write"],
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
@pytest.fixture
|
| 38 |
+
def expired_token(self, rsa_key_pair: RSAKeyPair) -> str:
|
| 39 |
+
"""Create an expired test token."""
|
| 40 |
+
return rsa_key_pair.create_token(
|
| 41 |
+
subject="test-user",
|
| 42 |
+
issuer="https://test.example.com",
|
| 43 |
+
audience="https://api.example.com",
|
| 44 |
+
expires_in_seconds=-3600, # Expired 1 hour ago
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
async def test_verify_token_with_valid_token(
|
| 48 |
+
self, bearer_provider: BearerAuthProvider, valid_token: str
|
| 49 |
+
):
|
| 50 |
+
"""Test that verify_token returns AccessToken for valid token."""
|
| 51 |
+
result = await bearer_provider.verify_token(valid_token)
|
| 52 |
+
|
| 53 |
+
assert result is not None
|
| 54 |
+
assert isinstance(result, AccessToken)
|
| 55 |
+
assert result.token == valid_token
|
| 56 |
+
assert result.client_id == "test-user"
|
| 57 |
+
assert "read" in result.scopes
|
| 58 |
+
assert "write" in result.scopes
|
| 59 |
+
|
| 60 |
+
async def test_verify_token_with_expired_token(
|
| 61 |
+
self, bearer_provider: BearerAuthProvider, expired_token: str
|
| 62 |
+
):
|
| 63 |
+
"""Test that verify_token returns None for expired token."""
|
| 64 |
+
result = await bearer_provider.verify_token(expired_token)
|
| 65 |
+
assert result is None
|
| 66 |
+
|
| 67 |
+
async def test_verify_token_with_invalid_token(
|
| 68 |
+
self, bearer_provider: BearerAuthProvider
|
| 69 |
+
):
|
| 70 |
+
"""Test that verify_token returns None for invalid token."""
|
| 71 |
+
result = await bearer_provider.verify_token("invalid.token.here")
|
| 72 |
+
assert result is None
|
| 73 |
+
|
| 74 |
+
async def test_verify_token_with_malformed_token(
|
| 75 |
+
self, bearer_provider: BearerAuthProvider
|
| 76 |
+
):
|
| 77 |
+
"""Test that verify_token returns None for malformed token."""
|
| 78 |
+
result = await bearer_provider.verify_token("not-a-jwt")
|
| 79 |
+
assert result is None
|
| 80 |
+
|
| 81 |
+
async def test_verify_token_delegation_to_load_access_token(
|
| 82 |
+
self, bearer_provider: BearerAuthProvider, valid_token: str
|
| 83 |
+
):
|
| 84 |
+
"""Test that verify_token delegates to load_access_token."""
|
| 85 |
+
# Both methods should return the same result
|
| 86 |
+
verify_result = await bearer_provider.verify_token(valid_token)
|
| 87 |
+
load_result = await bearer_provider.load_access_token(valid_token)
|
| 88 |
+
|
| 89 |
+
assert verify_result == load_result
|
| 90 |
+
if verify_result is not None and load_result is not None:
|
| 91 |
+
assert verify_result.token == load_result.token
|
| 92 |
+
assert verify_result.client_id == load_result.client_id
|
| 93 |
+
assert verify_result.scopes == load_result.scopes
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class TestInMemoryOAuthProviderTokenVerifier:
|
| 97 |
+
"""Test that InMemoryOAuthProvider implements TokenVerifier protocol correctly."""
|
| 98 |
+
|
| 99 |
+
@pytest.fixture
|
| 100 |
+
def in_memory_provider(self) -> InMemoryOAuthProvider:
|
| 101 |
+
"""Create InMemoryOAuthProvider for testing."""
|
| 102 |
+
return InMemoryOAuthProvider(
|
| 103 |
+
issuer_url="https://test.example.com",
|
| 104 |
+
required_scopes=["user"],
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
async def test_verify_token_with_nonexistent_token(
|
| 108 |
+
self, in_memory_provider: InMemoryOAuthProvider
|
| 109 |
+
):
|
| 110 |
+
"""Test that verify_token returns None for nonexistent token."""
|
| 111 |
+
result = await in_memory_provider.verify_token("nonexistent-token")
|
| 112 |
+
assert result is None
|
| 113 |
+
|
| 114 |
+
async def test_verify_token_delegation_to_load_access_token(
|
| 115 |
+
self, in_memory_provider: InMemoryOAuthProvider
|
| 116 |
+
):
|
| 117 |
+
"""Test that verify_token delegates to load_access_token."""
|
| 118 |
+
# Create a test token in the provider's storage
|
| 119 |
+
test_token = "test-access-token"
|
| 120 |
+
test_access_token = AccessToken(
|
| 121 |
+
token=test_token,
|
| 122 |
+
client_id="test-client",
|
| 123 |
+
scopes=["user"],
|
| 124 |
+
expires_at=None, # No expiry
|
| 125 |
+
)
|
| 126 |
+
in_memory_provider.access_tokens[test_token] = test_access_token
|
| 127 |
+
|
| 128 |
+
# Both methods should return the same result
|
| 129 |
+
verify_result = await in_memory_provider.verify_token(test_token)
|
| 130 |
+
load_result = await in_memory_provider.load_access_token(test_token)
|
| 131 |
+
|
| 132 |
+
assert verify_result == load_result
|
| 133 |
+
assert verify_result is not None
|
| 134 |
+
assert verify_result.token == test_token
|
| 135 |
+
assert verify_result.client_id == "test-client"
|
| 136 |
+
assert verify_result.scopes == ["user"]
|
| 137 |
+
|
| 138 |
+
async def test_verify_token_with_expired_token(
|
| 139 |
+
self, in_memory_provider: InMemoryOAuthProvider
|
| 140 |
+
):
|
| 141 |
+
"""Test that verify_token returns None for expired token."""
|
| 142 |
+
import time
|
| 143 |
+
|
| 144 |
+
# Create an expired token
|
| 145 |
+
expired_token = "expired-token"
|
| 146 |
+
expired_access_token = AccessToken(
|
| 147 |
+
token=expired_token,
|
| 148 |
+
client_id="test-client",
|
| 149 |
+
scopes=["user"],
|
| 150 |
+
expires_at=int(time.time()) - 3600, # Expired 1 hour ago
|
| 151 |
+
)
|
| 152 |
+
in_memory_provider.access_tokens[expired_token] = expired_access_token
|
| 153 |
+
|
| 154 |
+
result = await in_memory_provider.verify_token(expired_token)
|
| 155 |
+
assert result is None
|
| 156 |
+
|
| 157 |
+
# Token should be cleaned up from storage
|
| 158 |
+
assert expired_token not in in_memory_provider.access_tokens
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
class TestTokenVerifierProtocolCompliance:
|
| 162 |
+
"""Test that our providers properly implement the TokenVerifier protocol."""
|
| 163 |
+
|
| 164 |
+
async def test_bearer_provider_implements_protocol(self):
|
| 165 |
+
"""Test that BearerAuthProvider can be used as TokenVerifier."""
|
| 166 |
+
key_pair = RSAKeyPair.generate()
|
| 167 |
+
provider = BearerAuthProvider(public_key=key_pair.public_key)
|
| 168 |
+
|
| 169 |
+
# Should have the required method for TokenVerifier protocol
|
| 170 |
+
assert hasattr(provider, "verify_token")
|
| 171 |
+
assert callable(provider.verify_token)
|
| 172 |
+
|
| 173 |
+
async def test_in_memory_provider_implements_protocol(self):
|
| 174 |
+
"""Test that InMemoryOAuthProvider can be used as TokenVerifier."""
|
| 175 |
+
provider = InMemoryOAuthProvider()
|
| 176 |
+
|
| 177 |
+
# Should have the required method for TokenVerifier protocol
|
| 178 |
+
assert hasattr(provider, "verify_token")
|
| 179 |
+
assert callable(provider.verify_token)
|
tests/server/http/test_auth_setup.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for authentication setup in HTTP apps."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend
|
| 5 |
+
from mcp.server.auth.provider import AccessToken
|
| 6 |
+
from starlette.middleware import Middleware
|
| 7 |
+
from starlette.middleware.authentication import AuthenticationMiddleware
|
| 8 |
+
|
| 9 |
+
from fastmcp.server.auth.providers.bearer import BearerAuthProvider, RSAKeyPair
|
| 10 |
+
from fastmcp.server.auth.providers.in_memory import InMemoryOAuthProvider
|
| 11 |
+
from fastmcp.server.http import setup_auth_middleware_and_routes
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class TestSetupAuthMiddlewareAndRoutes:
|
| 15 |
+
"""Test setup_auth_middleware_and_routes with TokenVerifier providers."""
|
| 16 |
+
|
| 17 |
+
@pytest.fixture
|
| 18 |
+
def bearer_provider(self) -> BearerAuthProvider:
|
| 19 |
+
"""Create BearerAuthProvider for testing."""
|
| 20 |
+
key_pair = RSAKeyPair.generate()
|
| 21 |
+
return BearerAuthProvider(
|
| 22 |
+
public_key=key_pair.public_key,
|
| 23 |
+
issuer="https://test.example.com",
|
| 24 |
+
audience="https://api.example.com",
|
| 25 |
+
required_scopes=["read", "write"],
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
@pytest.fixture
|
| 29 |
+
def in_memory_provider(self) -> InMemoryOAuthProvider:
|
| 30 |
+
"""Create InMemoryOAuthProvider for testing."""
|
| 31 |
+
return InMemoryOAuthProvider(
|
| 32 |
+
issuer_url="https://test.example.com",
|
| 33 |
+
required_scopes=["user"],
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
def test_setup_with_bearer_provider(self, bearer_provider: BearerAuthProvider):
|
| 37 |
+
"""Test that setup works with BearerAuthProvider as TokenVerifier."""
|
| 38 |
+
middleware, auth_routes, required_scopes = setup_auth_middleware_and_routes(
|
| 39 |
+
bearer_provider
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
# Should return middleware list
|
| 43 |
+
assert isinstance(middleware, list)
|
| 44 |
+
assert len(middleware) == 2 # AuthenticationMiddleware + AuthContextMiddleware
|
| 45 |
+
|
| 46 |
+
# First middleware should be AuthenticationMiddleware with BearerAuthBackend
|
| 47 |
+
auth_middleware = middleware[0]
|
| 48 |
+
assert isinstance(auth_middleware, Middleware)
|
| 49 |
+
assert auth_middleware.cls == AuthenticationMiddleware
|
| 50 |
+
assert "backend" in auth_middleware.kwargs
|
| 51 |
+
|
| 52 |
+
backend = auth_middleware.kwargs["backend"]
|
| 53 |
+
assert isinstance(backend, BearerAuthBackend)
|
| 54 |
+
assert backend.token_verifier is bearer_provider # type: ignore[attr-defined]
|
| 55 |
+
|
| 56 |
+
# Should return auth routes
|
| 57 |
+
assert isinstance(auth_routes, list)
|
| 58 |
+
assert len(auth_routes) > 0 # Should have OAuth routes
|
| 59 |
+
|
| 60 |
+
# Should return required scopes
|
| 61 |
+
assert required_scopes == ["read", "write"]
|
| 62 |
+
|
| 63 |
+
def test_setup_with_in_memory_provider(
|
| 64 |
+
self, in_memory_provider: InMemoryOAuthProvider
|
| 65 |
+
):
|
| 66 |
+
"""Test that setup works with InMemoryOAuthProvider as TokenVerifier."""
|
| 67 |
+
middleware, auth_routes, required_scopes = setup_auth_middleware_and_routes(
|
| 68 |
+
in_memory_provider
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
# Should return middleware list
|
| 72 |
+
assert isinstance(middleware, list)
|
| 73 |
+
assert len(middleware) == 2
|
| 74 |
+
|
| 75 |
+
# Backend should use the provider as token verifier
|
| 76 |
+
auth_middleware = middleware[0]
|
| 77 |
+
backend = auth_middleware.kwargs["backend"]
|
| 78 |
+
assert isinstance(backend, BearerAuthBackend)
|
| 79 |
+
assert backend.token_verifier is in_memory_provider # type: ignore[attr-defined]
|
| 80 |
+
|
| 81 |
+
# Should return required scopes
|
| 82 |
+
assert required_scopes == ["user"]
|
| 83 |
+
|
| 84 |
+
def test_setup_preserves_provider_functionality(
|
| 85 |
+
self, bearer_provider: BearerAuthProvider
|
| 86 |
+
):
|
| 87 |
+
"""Test that setup doesn't break the provider's functionality."""
|
| 88 |
+
# Setup should not modify the provider
|
| 89 |
+
original_issuer = bearer_provider.issuer
|
| 90 |
+
original_scopes = bearer_provider.required_scopes
|
| 91 |
+
|
| 92 |
+
middleware, auth_routes, required_scopes = setup_auth_middleware_and_routes(
|
| 93 |
+
bearer_provider
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
# Provider should be unchanged
|
| 97 |
+
assert bearer_provider.issuer == original_issuer
|
| 98 |
+
assert bearer_provider.required_scopes == original_scopes
|
| 99 |
+
|
| 100 |
+
# Provider should still work as TokenVerifier
|
| 101 |
+
assert hasattr(bearer_provider, "verify_token")
|
| 102 |
+
assert callable(bearer_provider.verify_token)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
class MockOAuthProvider:
|
| 106 |
+
"""Mock OAuth provider that implements TokenVerifier."""
|
| 107 |
+
|
| 108 |
+
def __init__(self, required_scopes=None, issuer_url="http://localhost:8000"):
|
| 109 |
+
from pydantic import AnyHttpUrl
|
| 110 |
+
|
| 111 |
+
from fastmcp.server.auth.auth import (
|
| 112 |
+
ClientRegistrationOptions,
|
| 113 |
+
RevocationOptions,
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
self.required_scopes = required_scopes or []
|
| 117 |
+
self.issuer_url = AnyHttpUrl(issuer_url)
|
| 118 |
+
self.service_documentation_url = None
|
| 119 |
+
self.client_registration_options = ClientRegistrationOptions(enabled=False)
|
| 120 |
+
self.revocation_options = RevocationOptions(enabled=False)
|
| 121 |
+
|
| 122 |
+
async def verify_token(self, token: str) -> AccessToken | None:
|
| 123 |
+
"""Mock verify_token implementation."""
|
| 124 |
+
if token == "valid-token":
|
| 125 |
+
return AccessToken(
|
| 126 |
+
token=token,
|
| 127 |
+
client_id="mock-client",
|
| 128 |
+
scopes=self.required_scopes,
|
| 129 |
+
expires_at=None,
|
| 130 |
+
)
|
| 131 |
+
return None
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
class TestSetupWithMockProvider:
|
| 135 |
+
"""Test setup function with mock provider."""
|
| 136 |
+
|
| 137 |
+
def test_setup_with_mock_token_verifier(self):
|
| 138 |
+
"""Test that setup works with any TokenVerifier implementation."""
|
| 139 |
+
mock_provider = MockOAuthProvider(required_scopes=["mock-scope"])
|
| 140 |
+
|
| 141 |
+
middleware, auth_routes, required_scopes = setup_auth_middleware_and_routes(
|
| 142 |
+
mock_provider # type: ignore[arg-type]
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
# Should work with any TokenVerifier
|
| 146 |
+
assert len(middleware) == 2
|
| 147 |
+
auth_middleware = middleware[0]
|
| 148 |
+
backend = auth_middleware.kwargs["backend"]
|
| 149 |
+
assert isinstance(backend, BearerAuthBackend)
|
| 150 |
+
assert backend.token_verifier is mock_provider # type: ignore[attr-defined]
|
| 151 |
+
|
| 152 |
+
assert required_scopes == ["mock-scope"]
|
| 153 |
+
|
| 154 |
+
async def test_setup_middleware_can_authenticate(self):
|
| 155 |
+
"""Test that the setup middleware can actually authenticate requests."""
|
| 156 |
+
mock_provider = MockOAuthProvider()
|
| 157 |
+
|
| 158 |
+
middleware, _, _ = setup_auth_middleware_and_routes(mock_provider) # type: ignore[arg-type]
|
| 159 |
+
|
| 160 |
+
# Extract the BearerAuthBackend
|
| 161 |
+
auth_middleware = middleware[0]
|
| 162 |
+
backend = auth_middleware.kwargs["backend"]
|
| 163 |
+
|
| 164 |
+
# Test authentication with valid token
|
| 165 |
+
from starlette.requests import HTTPConnection
|
| 166 |
+
|
| 167 |
+
scope = {
|
| 168 |
+
"type": "http",
|
| 169 |
+
"headers": [(b"authorization", b"Bearer valid-token")],
|
| 170 |
+
}
|
| 171 |
+
conn = HTTPConnection(scope)
|
| 172 |
+
|
| 173 |
+
result = await backend.authenticate(conn) # type: ignore[attr-defined]
|
| 174 |
+
assert result is not None
|
| 175 |
+
|
| 176 |
+
credentials, user = result
|
| 177 |
+
assert user.username == "mock-client"
|
| 178 |
+
|
| 179 |
+
# Test authentication with invalid token
|
| 180 |
+
scope = {
|
| 181 |
+
"type": "http",
|
| 182 |
+
"headers": [(b"authorization", b"Bearer invalid-token")],
|
| 183 |
+
}
|
| 184 |
+
conn = HTTPConnection(scope)
|
| 185 |
+
|
| 186 |
+
result = await backend.authenticate(conn) # type: ignore[attr-defined]
|
| 187 |
+
assert result is None
|
tests/server/http/test_bearer_auth_backend.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for BearerAuthBackend integration with TokenVerifier."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend
|
| 5 |
+
from mcp.server.auth.provider import AccessToken
|
| 6 |
+
from starlette.requests import HTTPConnection
|
| 7 |
+
|
| 8 |
+
from fastmcp.server.auth.providers.bearer import BearerAuthProvider, RSAKeyPair
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class TestBearerAuthBackendTokenVerifierIntegration:
|
| 12 |
+
"""Test BearerAuthBackend works with TokenVerifier protocol."""
|
| 13 |
+
|
| 14 |
+
@pytest.fixture
|
| 15 |
+
def rsa_key_pair(self) -> RSAKeyPair:
|
| 16 |
+
"""Generate RSA key pair for testing."""
|
| 17 |
+
return RSAKeyPair.generate()
|
| 18 |
+
|
| 19 |
+
@pytest.fixture
|
| 20 |
+
def bearer_provider(self, rsa_key_pair: RSAKeyPair) -> BearerAuthProvider:
|
| 21 |
+
"""Create BearerAuthProvider for testing."""
|
| 22 |
+
return BearerAuthProvider(
|
| 23 |
+
public_key=rsa_key_pair.public_key,
|
| 24 |
+
issuer="https://test.example.com",
|
| 25 |
+
audience="https://api.example.com",
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
@pytest.fixture
|
| 29 |
+
def valid_token(self, rsa_key_pair: RSAKeyPair) -> str:
|
| 30 |
+
"""Create a valid test token."""
|
| 31 |
+
return rsa_key_pair.create_token(
|
| 32 |
+
subject="test-user",
|
| 33 |
+
issuer="https://test.example.com",
|
| 34 |
+
audience="https://api.example.com",
|
| 35 |
+
scopes=["read", "write"],
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
def test_bearer_auth_backend_constructor_accepts_token_verifier(
|
| 39 |
+
self, bearer_provider: BearerAuthProvider
|
| 40 |
+
):
|
| 41 |
+
"""Test that BearerAuthBackend constructor accepts TokenVerifier."""
|
| 42 |
+
# This should not raise an error
|
| 43 |
+
backend = BearerAuthBackend(bearer_provider)
|
| 44 |
+
assert backend.token_verifier is bearer_provider # type: ignore[attr-defined]
|
| 45 |
+
|
| 46 |
+
async def test_bearer_auth_backend_authenticate_with_valid_token(
|
| 47 |
+
self, bearer_provider: BearerAuthProvider, valid_token: str
|
| 48 |
+
):
|
| 49 |
+
"""Test BearerAuthBackend authentication with valid token."""
|
| 50 |
+
backend = BearerAuthBackend(bearer_provider)
|
| 51 |
+
|
| 52 |
+
# Create mock HTTPConnection with Authorization header
|
| 53 |
+
scope = {
|
| 54 |
+
"type": "http",
|
| 55 |
+
"headers": [(b"authorization", f"Bearer {valid_token}".encode())],
|
| 56 |
+
}
|
| 57 |
+
conn = HTTPConnection(scope)
|
| 58 |
+
|
| 59 |
+
result = await backend.authenticate(conn)
|
| 60 |
+
|
| 61 |
+
assert result is not None
|
| 62 |
+
credentials, user = result
|
| 63 |
+
assert credentials.scopes == ["read", "write"]
|
| 64 |
+
assert user.username == "test-user"
|
| 65 |
+
assert hasattr(user, "access_token")
|
| 66 |
+
assert user.access_token.token == valid_token
|
| 67 |
+
|
| 68 |
+
async def test_bearer_auth_backend_authenticate_with_invalid_token(
|
| 69 |
+
self, bearer_provider: BearerAuthProvider
|
| 70 |
+
):
|
| 71 |
+
"""Test BearerAuthBackend authentication with invalid token."""
|
| 72 |
+
backend = BearerAuthBackend(bearer_provider)
|
| 73 |
+
|
| 74 |
+
# Create mock HTTPConnection with invalid Authorization header
|
| 75 |
+
scope = {
|
| 76 |
+
"type": "http",
|
| 77 |
+
"headers": [(b"authorization", b"Bearer invalid-token")],
|
| 78 |
+
}
|
| 79 |
+
conn = HTTPConnection(scope)
|
| 80 |
+
|
| 81 |
+
result = await backend.authenticate(conn)
|
| 82 |
+
assert result is None
|
| 83 |
+
|
| 84 |
+
async def test_bearer_auth_backend_authenticate_with_no_header(
|
| 85 |
+
self, bearer_provider: BearerAuthProvider
|
| 86 |
+
):
|
| 87 |
+
"""Test BearerAuthBackend authentication with no Authorization header."""
|
| 88 |
+
backend = BearerAuthBackend(bearer_provider)
|
| 89 |
+
|
| 90 |
+
# Create mock HTTPConnection without Authorization header
|
| 91 |
+
scope = {
|
| 92 |
+
"type": "http",
|
| 93 |
+
"headers": [],
|
| 94 |
+
}
|
| 95 |
+
conn = HTTPConnection(scope)
|
| 96 |
+
|
| 97 |
+
result = await backend.authenticate(conn)
|
| 98 |
+
assert result is None
|
| 99 |
+
|
| 100 |
+
async def test_bearer_auth_backend_authenticate_with_non_bearer_token(
|
| 101 |
+
self, bearer_provider: BearerAuthProvider
|
| 102 |
+
):
|
| 103 |
+
"""Test BearerAuthBackend authentication with non-Bearer token."""
|
| 104 |
+
backend = BearerAuthBackend(bearer_provider)
|
| 105 |
+
|
| 106 |
+
# Create mock HTTPConnection with Basic auth header
|
| 107 |
+
scope = {
|
| 108 |
+
"type": "http",
|
| 109 |
+
"headers": [(b"authorization", b"Basic dXNlcjpwYXNz")],
|
| 110 |
+
}
|
| 111 |
+
conn = HTTPConnection(scope)
|
| 112 |
+
|
| 113 |
+
result = await backend.authenticate(conn)
|
| 114 |
+
assert result is None
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
class MockTokenVerifier:
|
| 118 |
+
"""Mock TokenVerifier for testing backend integration."""
|
| 119 |
+
|
| 120 |
+
def __init__(self, return_value: AccessToken | None = None):
|
| 121 |
+
self.return_value = return_value
|
| 122 |
+
self.verify_token_calls = []
|
| 123 |
+
|
| 124 |
+
async def verify_token(self, token: str) -> AccessToken | None:
|
| 125 |
+
"""Mock verify_token method."""
|
| 126 |
+
self.verify_token_calls.append(token)
|
| 127 |
+
return self.return_value
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
class TestBearerAuthBackendWithMockVerifier:
|
| 131 |
+
"""Test BearerAuthBackend with mock TokenVerifier."""
|
| 132 |
+
|
| 133 |
+
async def test_backend_calls_verify_token_method(self):
|
| 134 |
+
"""Test that BearerAuthBackend calls verify_token on the verifier."""
|
| 135 |
+
mock_access_token = AccessToken(
|
| 136 |
+
token="test-token",
|
| 137 |
+
client_id="test-client",
|
| 138 |
+
scopes=["read"],
|
| 139 |
+
expires_at=None,
|
| 140 |
+
)
|
| 141 |
+
mock_verifier = MockTokenVerifier(return_value=mock_access_token)
|
| 142 |
+
backend = BearerAuthBackend(mock_verifier) # type: ignore[arg-type]
|
| 143 |
+
|
| 144 |
+
scope = {
|
| 145 |
+
"type": "http",
|
| 146 |
+
"headers": [(b"authorization", b"Bearer test-token")],
|
| 147 |
+
}
|
| 148 |
+
conn = HTTPConnection(scope)
|
| 149 |
+
|
| 150 |
+
result = await backend.authenticate(conn)
|
| 151 |
+
|
| 152 |
+
# Should have called verify_token with the token
|
| 153 |
+
assert mock_verifier.verify_token_calls == ["test-token"]
|
| 154 |
+
|
| 155 |
+
# Should return authentication result
|
| 156 |
+
assert result is not None
|
| 157 |
+
credentials, user = result
|
| 158 |
+
assert credentials.scopes == ["read"]
|
| 159 |
+
assert user.username == "test-client"
|
| 160 |
+
|
| 161 |
+
async def test_backend_handles_verify_token_none_result(self):
|
| 162 |
+
"""Test that BearerAuthBackend handles None result from verify_token."""
|
| 163 |
+
mock_verifier = MockTokenVerifier(return_value=None)
|
| 164 |
+
backend = BearerAuthBackend(mock_verifier) # type: ignore[arg-type]
|
| 165 |
+
|
| 166 |
+
scope = {
|
| 167 |
+
"type": "http",
|
| 168 |
+
"headers": [(b"authorization", b"Bearer invalid-token")],
|
| 169 |
+
}
|
| 170 |
+
conn = HTTPConnection(scope)
|
| 171 |
+
|
| 172 |
+
result = await backend.authenticate(conn)
|
| 173 |
+
|
| 174 |
+
# Should have called verify_token
|
| 175 |
+
assert mock_verifier.verify_token_calls == ["invalid-token"]
|
| 176 |
+
|
| 177 |
+
# Should return None for authentication failure
|
| 178 |
+
assert result is None
|
uv.lock
CHANGED
|
@@ -482,7 +482,7 @@ requires-dist = [
|
|
| 482 |
{ name = "authlib", specifier = ">=1.5.2" },
|
| 483 |
{ name = "exceptiongroup", specifier = ">=1.2.2" },
|
| 484 |
{ name = "httpx", specifier = ">=0.28.1" },
|
| 485 |
-
{ name = "mcp",
|
| 486 |
{ name = "openapi-pydantic", specifier = ">=0.5.1" },
|
| 487 |
{ name = "python-dotenv", specifier = ">=1.1.0" },
|
| 488 |
{ name = "rich", specifier = ">=13.9.4" },
|
|
@@ -746,8 +746,8 @@ wheels = [
|
|
| 746 |
|
| 747 |
[[package]]
|
| 748 |
name = "mcp"
|
| 749 |
-
version = "1.10.
|
| 750 |
-
source = {
|
| 751 |
dependencies = [
|
| 752 |
{ name = "anyio" },
|
| 753 |
{ name = "httpx" },
|
|
@@ -760,6 +760,10 @@ dependencies = [
|
|
| 760 |
{ name = "starlette" },
|
| 761 |
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
| 762 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 763 |
|
| 764 |
[[package]]
|
| 765 |
name = "mdurl"
|
|
|
|
| 482 |
{ name = "authlib", specifier = ">=1.5.2" },
|
| 483 |
{ name = "exceptiongroup", specifier = ">=1.2.2" },
|
| 484 |
{ name = "httpx", specifier = ">=0.28.1" },
|
| 485 |
+
{ name = "mcp", specifier = ">=1.10.0" },
|
| 486 |
{ name = "openapi-pydantic", specifier = ">=0.5.1" },
|
| 487 |
{ name = "python-dotenv", specifier = ">=1.1.0" },
|
| 488 |
{ name = "rich", specifier = ">=13.9.4" },
|
|
|
|
| 746 |
|
| 747 |
[[package]]
|
| 748 |
name = "mcp"
|
| 749 |
+
version = "1.10.0"
|
| 750 |
+
source = { registry = "https://pypi.org/simple" }
|
| 751 |
dependencies = [
|
| 752 |
{ name = "anyio" },
|
| 753 |
{ name = "httpx" },
|
|
|
|
| 760 |
{ name = "starlette" },
|
| 761 |
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
| 762 |
]
|
| 763 |
+
sdist = { url = "https://files.pythonhosted.org/packages/c8/1a/d90e42be23a7e6dd35c03e35c7c63fe1036f082d3bb88114b66bd0f2467e/mcp-1.10.0.tar.gz", hash = "sha256:91fb1623c3faf14577623d14755d3213db837c5da5dae85069e1b59124cbe0e9", size = 392961, upload-time = "2025-06-26T13:51:19.025Z" }
|
| 764 |
+
wheels = [
|
| 765 |
+
{ url = "https://files.pythonhosted.org/packages/0f/52/e1c43c4b5153465fd5d3b4b41bf2d4c7731475e9f668f38d68f848c25c9a/mcp-1.10.0-py3-none-any.whl", hash = "sha256:925c45482d75b1b6f11febddf9736d55edf7739c7ea39b583309f6651cbc9e5c", size = 150894, upload-time = "2025-06-26T13:51:17.342Z" },
|
| 766 |
+
]
|
| 767 |
|
| 768 |
[[package]]
|
| 769 |
name = "mdurl"
|