Spaces:
Running
Running
| from collections.abc import Generator | |
| from typing import Any | |
| import httpx | |
| import pytest | |
| from pytest_httpx import HTTPXMock | |
| from fastmcp import Client, FastMCP | |
| from fastmcp.client.auth.bearer import BearerAuth | |
| from fastmcp.server.auth.providers.jwt import JWKData, JWKSData, JWTVerifier, RSAKeyPair | |
| from fastmcp.utilities.tests import run_server_in_process | |
| def rsa_key_pair() -> RSAKeyPair: | |
| return RSAKeyPair.generate() | |
| def bearer_token(rsa_key_pair: RSAKeyPair) -> str: | |
| return rsa_key_pair.create_token( | |
| subject="test-user", | |
| issuer="https://test.example.com", | |
| audience="https://api.example.com", | |
| ) | |
| def bearer_provider(rsa_key_pair: RSAKeyPair) -> JWTVerifier: | |
| return JWTVerifier( | |
| public_key=rsa_key_pair.public_key, | |
| issuer="https://test.example.com", | |
| audience="https://api.example.com", | |
| ) | |
| def run_mcp_server( | |
| public_key: str, | |
| host: str, | |
| port: int, | |
| auth_kwargs: dict[str, Any] | None = None, | |
| run_kwargs: dict[str, Any] | None = None, | |
| ) -> None: | |
| mcp = FastMCP( | |
| auth=JWTVerifier( | |
| public_key=public_key, | |
| **auth_kwargs or {}, | |
| ) | |
| ) | |
| def add(a: int, b: int) -> int: | |
| return a + b | |
| mcp.run(host=host, port=port, **run_kwargs or {}) | |
| def mcp_server_url(rsa_key_pair: RSAKeyPair) -> Generator[str]: | |
| with run_server_in_process( | |
| run_mcp_server, | |
| public_key=rsa_key_pair.public_key, | |
| run_kwargs=dict(transport="http"), | |
| ) as url: | |
| yield f"{url}/mcp" | |
| class TestRSAKeyPair: | |
| def test_generate_key_pair(self): | |
| """Test RSA key pair generation.""" | |
| key_pair = RSAKeyPair.generate() | |
| assert key_pair.private_key is not None | |
| assert key_pair.public_key is not None | |
| # Check that keys are in PEM format | |
| private_pem = key_pair.private_key.get_secret_value() | |
| public_pem = key_pair.public_key | |
| assert "-----BEGIN PRIVATE KEY-----" in private_pem | |
| assert "-----END PRIVATE KEY-----" in private_pem | |
| assert "-----BEGIN PUBLIC KEY-----" in public_pem | |
| assert "-----END PUBLIC KEY-----" in public_pem | |
| def test_create_basic_token(self, rsa_key_pair: RSAKeyPair): | |
| """Test basic token creation.""" | |
| token = rsa_key_pair.create_token( | |
| subject="test-user", | |
| issuer="https://test.example.com", | |
| ) | |
| assert isinstance(token, str) | |
| assert len(token.split(".")) == 3 # JWT has 3 parts | |
| def test_create_token_with_scopes(self, rsa_key_pair: RSAKeyPair): | |
| """Test token creation with scopes.""" | |
| token = rsa_key_pair.create_token( | |
| subject="test-user", | |
| issuer="https://test.example.com", | |
| scopes=["read", "write"], | |
| ) | |
| assert isinstance(token, str) | |
| # We'll validate the scopes in the BearerToken tests | |
| class TestBearerTokenJWKS: | |
| """Tests for JWKS URI functionality.""" | |
| def jwks_provider(self, rsa_key_pair: RSAKeyPair) -> JWTVerifier: | |
| """Provider configured with JWKS URI.""" | |
| return JWTVerifier( | |
| jwks_uri="https://test.example.com/.well-known/jwks.json", | |
| issuer="https://test.example.com", | |
| audience="https://api.example.com", | |
| ) | |
| def mock_jwks_data(self, rsa_key_pair: RSAKeyPair) -> JWKSData: | |
| """Create mock JWKS data from RSA key pair.""" | |
| from authlib.jose import JsonWebKey | |
| # Create JWK from the RSA public key | |
| jwk = JsonWebKey.import_key(rsa_key_pair.public_key) # type: ignore | |
| jwk_data: JWKData = jwk.as_dict() # type: ignore | |
| jwk_data["kid"] = "test-key-1" | |
| jwk_data["alg"] = "RS256" | |
| return {"keys": [jwk_data]} | |
| async def test_jwks_token_validation( | |
| self, | |
| rsa_key_pair: RSAKeyPair, | |
| jwks_provider: JWTVerifier, | |
| mock_jwks_data: JWKSData, | |
| httpx_mock: HTTPXMock, | |
| ): | |
| """Test token validation using JWKS URI.""" | |
| httpx_mock.add_response( | |
| url="https://test.example.com/.well-known/jwks.json", | |
| json=mock_jwks_data, | |
| ) | |
| username = "test-user" | |
| issuer = "https://test.example.com" | |
| audience = "https://api.example.com" | |
| token = rsa_key_pair.create_token( | |
| subject=username, | |
| issuer=issuer, | |
| audience=audience, | |
| ) | |
| access_token = await jwks_provider.load_access_token(token) | |
| assert access_token is not None | |
| assert access_token.client_id == username | |
| # ensure the raw claims are present - #1398 | |
| assert access_token.claims.get("sub") == username | |
| assert access_token.claims.get("iss") == issuer | |
| assert access_token.claims.get("aud") == audience | |
| async def test_jwks_token_validation_with_invalid_key( | |
| self, | |
| rsa_key_pair: RSAKeyPair, | |
| jwks_provider: JWTVerifier, | |
| mock_jwks_data: JWKSData, | |
| httpx_mock: HTTPXMock, | |
| ): | |
| httpx_mock.add_response( | |
| url="https://test.example.com/.well-known/jwks.json", | |
| json=mock_jwks_data, | |
| ) | |
| token = RSAKeyPair.generate().create_token( | |
| subject="test-user", | |
| issuer="https://test.example.com", | |
| audience="https://api.example.com", | |
| ) | |
| access_token = await jwks_provider.load_access_token(token) | |
| assert access_token is None | |
| async def test_jwks_token_validation_with_kid( | |
| self, | |
| rsa_key_pair: RSAKeyPair, | |
| jwks_provider: JWTVerifier, | |
| mock_jwks_data: JWKSData, | |
| httpx_mock: HTTPXMock, | |
| ): | |
| mock_jwks_data["keys"][0]["kid"] = "test-key-1" | |
| httpx_mock.add_response( | |
| url="https://test.example.com/.well-known/jwks.json", | |
| json=mock_jwks_data, | |
| ) | |
| token = rsa_key_pair.create_token( | |
| subject="test-user", | |
| issuer="https://test.example.com", | |
| audience="https://api.example.com", | |
| kid="test-key-1", | |
| ) | |
| access_token = await jwks_provider.load_access_token(token) | |
| assert access_token is not None | |
| assert access_token.client_id == "test-user" | |
| async def test_jwks_token_validation_with_kid_and_no_kid_in_token( | |
| self, | |
| rsa_key_pair: RSAKeyPair, | |
| jwks_provider: JWTVerifier, | |
| mock_jwks_data: JWKSData, | |
| httpx_mock: HTTPXMock, | |
| ): | |
| mock_jwks_data["keys"][0]["kid"] = "test-key-1" | |
| httpx_mock.add_response( | |
| url="https://test.example.com/.well-known/jwks.json", | |
| json=mock_jwks_data, | |
| ) | |
| token = rsa_key_pair.create_token( | |
| subject="test-user", | |
| issuer="https://test.example.com", | |
| audience="https://api.example.com", | |
| ) | |
| access_token = await jwks_provider.load_access_token(token) | |
| assert access_token is not None | |
| assert access_token.client_id == "test-user" | |
| async def test_jwks_token_validation_with_no_kid_and_kid_in_jwks( | |
| self, | |
| rsa_key_pair: RSAKeyPair, | |
| jwks_provider: JWTVerifier, | |
| mock_jwks_data: JWKSData, | |
| httpx_mock: HTTPXMock, | |
| ): | |
| mock_jwks_data["keys"][0]["kid"] = "test-key-1" | |
| httpx_mock.add_response( | |
| url="https://test.example.com/.well-known/jwks.json", | |
| json=mock_jwks_data, | |
| ) | |
| token = rsa_key_pair.create_token( | |
| subject="test-user", | |
| issuer="https://test.example.com", | |
| audience="https://api.example.com", | |
| ) | |
| access_token = await jwks_provider.load_access_token(token) | |
| assert access_token is not None | |
| assert access_token.client_id == "test-user" | |
| async def test_jwks_token_validation_with_kid_mismatch( | |
| self, | |
| rsa_key_pair: RSAKeyPair, | |
| jwks_provider: JWTVerifier, | |
| mock_jwks_data: JWKSData, | |
| httpx_mock: HTTPXMock, | |
| ): | |
| mock_jwks_data["keys"][0]["kid"] = "test-key-1" | |
| httpx_mock.add_response( | |
| url="https://test.example.com/.well-known/jwks.json", | |
| json=mock_jwks_data, | |
| ) | |
| token = rsa_key_pair.create_token( | |
| subject="test-user", | |
| issuer="https://test.example.com", | |
| audience="https://api.example.com", | |
| kid="test-key-2", | |
| ) | |
| access_token = await jwks_provider.load_access_token(token) | |
| assert access_token is None | |
| async def test_jwks_token_validation_with_multiple_keys_and_no_kid_in_token( | |
| self, | |
| rsa_key_pair: RSAKeyPair, | |
| jwks_provider: JWTVerifier, | |
| mock_jwks_data: JWKSData, | |
| httpx_mock: HTTPXMock, | |
| ): | |
| mock_jwks_data["keys"] = [ | |
| { | |
| "kid": "test-key-1", | |
| "alg": "RS256", | |
| }, | |
| { | |
| "kid": "test-key-2", | |
| "alg": "RS256", | |
| }, | |
| ] | |
| httpx_mock.add_response( | |
| url="https://test.example.com/.well-known/jwks.json", | |
| json=mock_jwks_data, | |
| ) | |
| token = rsa_key_pair.create_token( | |
| subject="test-user", | |
| issuer="https://test.example.com", | |
| audience="https://api.example.com", | |
| ) | |
| access_token = await jwks_provider.load_access_token(token) | |
| assert access_token is None | |
| class TestBearerToken: | |
| def test_initialization_with_public_key(self, rsa_key_pair: RSAKeyPair): | |
| """Test provider initialization with public key.""" | |
| provider = JWTVerifier( | |
| public_key=rsa_key_pair.public_key, issuer="https://test.example.com" | |
| ) | |
| assert provider.issuer == "https://test.example.com" | |
| assert provider.public_key is not None | |
| assert provider.jwks_uri is None | |
| def test_initialization_with_jwks_uri(self): | |
| """Test provider initialization with JWKS URI.""" | |
| provider = JWTVerifier( | |
| jwks_uri="https://test.example.com/.well-known/jwks.json", | |
| issuer="https://test.example.com", | |
| ) | |
| assert provider.issuer == "https://test.example.com" | |
| assert provider.jwks_uri == "https://test.example.com/.well-known/jwks.json" | |
| assert provider.public_key is None | |
| def test_initialization_requires_key_or_uri(self): | |
| """Test that either public_key or jwks_uri is required.""" | |
| with pytest.raises( | |
| ValueError, match="Either public_key or jwks_uri must be provided" | |
| ): | |
| JWTVerifier(issuer="https://test.example.com") | |
| def test_initialization_rejects_both_key_and_uri(self, rsa_key_pair: RSAKeyPair): | |
| """Test that both public_key and jwks_uri cannot be provided.""" | |
| with pytest.raises( | |
| ValueError, match="Provide either public_key or jwks_uri, not both" | |
| ): | |
| JWTVerifier( | |
| public_key=rsa_key_pair.public_key, | |
| jwks_uri="https://test.example.com/.well-known/jwks.json", | |
| issuer="https://test.example.com", | |
| ) | |
| async def test_valid_token_validation( | |
| self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier | |
| ): | |
| """Test validation of a valid token.""" | |
| token = rsa_key_pair.create_token( | |
| subject="test-user", | |
| issuer="https://test.example.com", | |
| audience="https://api.example.com", | |
| scopes=["read", "write"], | |
| ) | |
| access_token = await bearer_provider.load_access_token(token) | |
| assert access_token is not None | |
| assert access_token.client_id == "test-user" | |
| assert "read" in access_token.scopes | |
| assert "write" in access_token.scopes | |
| assert access_token.expires_at is not None | |
| async def test_expired_token_rejection( | |
| self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier | |
| ): | |
| """Test rejection of expired tokens.""" | |
| token = rsa_key_pair.create_token( | |
| subject="test-user", | |
| issuer="https://test.example.com", | |
| audience="https://api.example.com", | |
| expires_in_seconds=-3600, # Expired 1 hour ago | |
| ) | |
| access_token = await bearer_provider.load_access_token(token) | |
| assert access_token is None | |
| async def test_invalid_issuer_rejection( | |
| self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier | |
| ): | |
| """Test rejection of tokens with invalid issuer.""" | |
| token = rsa_key_pair.create_token( | |
| subject="test-user", | |
| issuer="https://evil.example.com", # Wrong issuer | |
| audience="https://api.example.com", | |
| ) | |
| access_token = await bearer_provider.load_access_token(token) | |
| assert access_token is None | |
| async def test_invalid_audience_rejection( | |
| self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier | |
| ): | |
| """Test rejection of tokens with invalid audience.""" | |
| token = rsa_key_pair.create_token( | |
| subject="test-user", | |
| issuer="https://test.example.com", | |
| audience="https://wrong-api.example.com", # Wrong audience | |
| ) | |
| access_token = await bearer_provider.load_access_token(token) | |
| assert access_token is None | |
| async def test_no_issuer_validation_when_none(self, rsa_key_pair: RSAKeyPair): | |
| """Test that issuer validation is skipped when provider has no issuer configured.""" | |
| provider = JWTVerifier( | |
| public_key=rsa_key_pair.public_key, | |
| issuer=None, # No issuer validation | |
| ) | |
| token = rsa_key_pair.create_token( | |
| subject="test-user", issuer="https://any.example.com" | |
| ) | |
| access_token = await provider.load_access_token(token) | |
| assert access_token is not None | |
| async def test_no_audience_validation_when_none(self, rsa_key_pair: RSAKeyPair): | |
| """Test that audience validation is skipped when provider has no audience configured.""" | |
| provider = JWTVerifier( | |
| public_key=rsa_key_pair.public_key, | |
| issuer="https://test.example.com", | |
| audience=None, # No audience validation | |
| ) | |
| token = rsa_key_pair.create_token( | |
| subject="test-user", | |
| issuer="https://test.example.com", | |
| audience="https://any-api.example.com", | |
| ) | |
| access_token = await provider.load_access_token(token) | |
| assert access_token is not None | |
| async def test_multiple_audiences_validation(self, rsa_key_pair: RSAKeyPair): | |
| """Test validation with multiple audiences in token.""" | |
| provider = JWTVerifier( | |
| public_key=rsa_key_pair.public_key, | |
| issuer="https://test.example.com", | |
| audience="https://api.example.com", | |
| ) | |
| token = rsa_key_pair.create_token( | |
| subject="test-user", | |
| issuer="https://test.example.com", | |
| additional_claims={ | |
| "aud": ["https://api.example.com", "https://other-api.example.com"] | |
| }, | |
| ) | |
| access_token = await provider.load_access_token(token) | |
| assert access_token is not None | |
| async def test_provider_with_multiple_expected_audiences( | |
| self, rsa_key_pair: RSAKeyPair | |
| ): | |
| """Test provider configured with multiple expected audiences.""" | |
| provider = JWTVerifier( | |
| public_key=rsa_key_pair.public_key, | |
| issuer="https://test.example.com", | |
| audience=["https://api.example.com", "https://other-api.example.com"], | |
| ) | |
| # Token with single audience that matches one of the expected | |
| token1 = rsa_key_pair.create_token( | |
| subject="test-user", | |
| issuer="https://test.example.com", | |
| audience="https://api.example.com", | |
| ) | |
| access_token1 = await provider.load_access_token(token1) | |
| assert access_token1 is not None | |
| # Token with multiple audiences, one of which matches | |
| token2 = rsa_key_pair.create_token( | |
| subject="test-user", | |
| issuer="https://test.example.com", | |
| additional_claims={ | |
| "aud": ["https://api.example.com", "https://third-party.example.com"] | |
| }, | |
| ) | |
| access_token2 = await provider.load_access_token(token2) | |
| assert access_token2 is not None | |
| # Token with audience that doesn't match any expected | |
| token3 = rsa_key_pair.create_token( | |
| subject="test-user", | |
| issuer="https://test.example.com", | |
| audience="https://wrong-api.example.com", | |
| ) | |
| access_token3 = await provider.load_access_token(token3) | |
| assert access_token3 is None | |
| async def test_scope_extraction_string( | |
| self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier | |
| ): | |
| """Test scope extraction from space-separated string.""" | |
| token = rsa_key_pair.create_token( | |
| subject="test-user", | |
| issuer="https://test.example.com", | |
| audience="https://api.example.com", | |
| scopes=["read", "write", "admin"], | |
| ) | |
| access_token = await bearer_provider.load_access_token(token) | |
| assert access_token is not None | |
| assert set(access_token.scopes) == {"read", "write", "admin"} | |
| async def test_scope_extraction_list( | |
| self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier | |
| ): | |
| """Test scope extraction from list format.""" | |
| token = rsa_key_pair.create_token( | |
| subject="test-user", | |
| issuer="https://test.example.com", | |
| audience="https://api.example.com", | |
| additional_claims={"scope": ["read", "write"]}, # List format | |
| ) | |
| access_token = await bearer_provider.load_access_token(token) | |
| assert access_token is not None | |
| assert set(access_token.scopes) == {"read", "write"} | |
| async def test_no_scopes( | |
| self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier | |
| ): | |
| """Test token with no scopes.""" | |
| token = rsa_key_pair.create_token( | |
| subject="test-user", | |
| issuer="https://test.example.com", | |
| audience="https://api.example.com", | |
| # No scopes | |
| ) | |
| access_token = await bearer_provider.load_access_token(token) | |
| assert access_token is not None | |
| assert access_token.scopes == [] | |
| async def test_scp_claim_extraction_string( | |
| self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier | |
| ): | |
| """Test scope extraction from 'scp' claim with space-separated string.""" | |
| token = rsa_key_pair.create_token( | |
| subject="test-user", | |
| issuer="https://test.example.com", | |
| audience="https://api.example.com", | |
| additional_claims={"scp": "read write admin"}, # 'scp' claim as string | |
| ) | |
| access_token = await bearer_provider.load_access_token(token) | |
| assert access_token is not None | |
| assert set(access_token.scopes) == {"read", "write", "admin"} | |
| async def test_scp_claim_extraction_list( | |
| self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier | |
| ): | |
| """Test scope extraction from 'scp' claim with list format.""" | |
| token = rsa_key_pair.create_token( | |
| subject="test-user", | |
| issuer="https://test.example.com", | |
| audience="https://api.example.com", | |
| additional_claims={ | |
| "scp": ["read", "write", "admin"] | |
| }, # 'scp' claim as list | |
| ) | |
| access_token = await bearer_provider.load_access_token(token) | |
| assert access_token is not None | |
| assert set(access_token.scopes) == {"read", "write", "admin"} | |
| async def test_scope_precedence_over_scp( | |
| self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier | |
| ): | |
| """Test that 'scope' claim takes precedence over 'scp' claim when both are present.""" | |
| token = rsa_key_pair.create_token( | |
| subject="test-user", | |
| issuer="https://test.example.com", | |
| audience="https://api.example.com", | |
| additional_claims={ | |
| "scope": "read write", # Standard OAuth2 claim | |
| "scp": "admin delete", # Should be ignored when 'scope' is present | |
| }, | |
| ) | |
| access_token = await bearer_provider.load_access_token(token) | |
| assert access_token is not None | |
| assert set(access_token.scopes) == {"read", "write"} # Only 'scope' claim used | |
| async def test_malformed_token_rejection(self, bearer_provider: JWTVerifier): | |
| """Test rejection of malformed tokens.""" | |
| malformed_tokens = [ | |
| "not.a.jwt", | |
| "too.many.parts.here.invalid", | |
| "invalid-token", | |
| "", | |
| "header.body", # Missing signature | |
| ] | |
| for token in malformed_tokens: | |
| access_token = await bearer_provider.load_access_token(token) | |
| assert access_token is None | |
| async def test_invalid_signature_rejection( | |
| self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier | |
| ): | |
| """Test rejection of tokens with invalid signatures.""" | |
| # Create a token with a different key pair | |
| other_key_pair = RSAKeyPair.generate() | |
| token = other_key_pair.create_token( | |
| subject="test-user", | |
| issuer="https://test.example.com", | |
| audience="https://api.example.com", | |
| ) | |
| access_token = await bearer_provider.load_access_token(token) | |
| assert access_token is None | |
| async def test_client_id_fallback( | |
| self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier | |
| ): | |
| """Test client_id extraction with fallback logic.""" | |
| # Test with explicit client_id claim | |
| token = rsa_key_pair.create_token( | |
| subject="user123", | |
| issuer="https://test.example.com", | |
| audience="https://api.example.com", | |
| additional_claims={"client_id": "app456"}, | |
| ) | |
| access_token = await bearer_provider.load_access_token(token) | |
| assert access_token is not None | |
| assert access_token.client_id == "app456" # Should prefer client_id over sub | |
| async def test_string_issuer_validation(self, rsa_key_pair: RSAKeyPair): | |
| """Test that string (non-URL) issuers are supported per RFC 7519.""" | |
| # Create provider with string issuer | |
| provider = JWTVerifier( | |
| public_key=rsa_key_pair.public_key, | |
| issuer="my-service", # String issuer, not a URL | |
| ) | |
| # Create token with matching string issuer | |
| token = rsa_key_pair.create_token( | |
| subject="test-user", | |
| issuer="my-service", # Same string issuer | |
| ) | |
| access_token = await provider.load_access_token(token) | |
| assert access_token is not None | |
| assert access_token.client_id == "test-user" | |
| async def test_string_issuer_mismatch_rejection(self, rsa_key_pair: RSAKeyPair): | |
| """Test that mismatched string issuers are rejected.""" | |
| # Create provider with one string issuer | |
| provider = JWTVerifier( | |
| public_key=rsa_key_pair.public_key, | |
| issuer="my-service", | |
| ) | |
| # Create token with different string issuer | |
| token = rsa_key_pair.create_token( | |
| subject="test-user", | |
| issuer="other-service", # Different string issuer | |
| ) | |
| access_token = await provider.load_access_token(token) | |
| assert access_token is None | |
| async def test_url_issuer_still_works(self, rsa_key_pair: RSAKeyPair): | |
| """Test that URL issuers still work after the fix.""" | |
| # Create provider with URL issuer | |
| provider = JWTVerifier( | |
| public_key=rsa_key_pair.public_key, | |
| issuer="https://my-auth-server.com", # URL issuer | |
| ) | |
| # Create token with matching URL issuer | |
| token = rsa_key_pair.create_token( | |
| subject="test-user", | |
| issuer="https://my-auth-server.com", # Same URL issuer | |
| ) | |
| access_token = await provider.load_access_token(token) | |
| assert access_token is not None | |
| assert access_token.client_id == "test-user" | |
| class TestFastMCPBearerAuth: | |
| def test_bearer_auth(self): | |
| mcp = FastMCP( | |
| auth=JWTVerifier(issuer="https://test.example.com", public_key="abc") | |
| ) | |
| assert isinstance(mcp.auth, JWTVerifier) | |
| 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 exc_info.value.response.status_code == 401 | |
| assert "tools" not in locals() | |
| async def test_authorized_access(self, mcp_server_url: str, bearer_token): | |
| async with Client(mcp_server_url, auth=BearerAuth(bearer_token)) as client: | |
| tools = await client.list_tools() # noqa: F841 | |
| assert tools | |
| async def test_invalid_token_raises_401(self, mcp_server_url: str): | |
| with pytest.raises(httpx.HTTPStatusError) as exc_info: | |
| async with Client(mcp_server_url, auth=BearerAuth("invalid")) as client: | |
| tools = await client.list_tools() # noqa: F841 | |
| assert exc_info.value.response.status_code == 401 | |
| assert "tools" not in locals() | |
| async def test_expired_token(self, mcp_server_url: str, rsa_key_pair: RSAKeyPair): | |
| token = rsa_key_pair.create_token( | |
| subject="test-user", | |
| issuer="https://test.example.com", | |
| audience="https://api.example.com", | |
| expires_in_seconds=-3600, | |
| ) | |
| with pytest.raises(httpx.HTTPStatusError) as exc_info: | |
| async with Client(mcp_server_url, auth=BearerAuth(token)) as client: | |
| tools = await client.list_tools() # noqa: F841 | |
| assert exc_info.value.response.status_code == 401 | |
| assert "tools" not in locals() | |
| async def test_token_with_bad_signature(self, mcp_server_url: str): | |
| rsa_key_pair = RSAKeyPair.generate() | |
| token = rsa_key_pair.create_token() | |
| with pytest.raises(httpx.HTTPStatusError) as exc_info: | |
| async with Client(mcp_server_url, auth=BearerAuth(token)) as client: | |
| tools = await client.list_tools() # noqa: F841 | |
| assert exc_info.value.response.status_code == 401 | |
| assert "tools" not in locals() | |
| async def test_token_with_insufficient_scopes( | |
| self, mcp_server_url: str, rsa_key_pair: RSAKeyPair | |
| ): | |
| token = rsa_key_pair.create_token( | |
| subject="test-user", | |
| issuer="https://test.example.com", | |
| audience="https://api.example.com", | |
| scopes=["read"], | |
| ) | |
| with run_server_in_process( | |
| run_mcp_server, | |
| public_key=rsa_key_pair.public_key, | |
| auth_kwargs=dict(required_scopes=["read", "write"]), | |
| run_kwargs=dict(transport="http"), | |
| ) as url: | |
| mcp_server_url = f"{url}/mcp/" | |
| with pytest.raises(httpx.HTTPStatusError) as exc_info: | |
| async with Client(mcp_server_url, auth=BearerAuth(token)) as client: | |
| tools = await client.list_tools() # noqa: F841 | |
| # JWTVerifier returns 401 when verify_token returns None (invalid token) | |
| # This is correct behavior - when TokenVerifier.verify_token returns None, | |
| # it indicates the token is invalid (not just insufficient permissions) | |
| assert exc_info.value.response.status_code == 401 | |
| assert "tools" not in locals() | |
| async def test_token_with_sufficient_scopes( | |
| self, mcp_server_url: str, rsa_key_pair: RSAKeyPair | |
| ): | |
| token = rsa_key_pair.create_token( | |
| subject="test-user", | |
| issuer="https://test.example.com", | |
| audience="https://api.example.com", | |
| scopes=["read", "write"], | |
| ) | |
| with run_server_in_process( | |
| run_mcp_server, | |
| public_key=rsa_key_pair.public_key, | |
| auth_kwargs=dict(required_scopes=["read", "write"]), | |
| run_kwargs=dict(transport="http"), | |
| ) as url: | |
| mcp_server_url = f"{url}/mcp/" | |
| async with Client(mcp_server_url, auth=BearerAuth(token)) as client: | |
| tools = await client.list_tools() | |
| assert tools | |
| class TestJWTVerifierImport: | |
| """Test JWT token verifier can be imported and created.""" | |
| def test_jwt_verifier_requires_pyjwt(self): | |
| """Test that JWTVerifier raises helpful error without PyJWT.""" | |
| # Since PyJWT is likely installed in test environment, we'll just test construction | |
| from fastmcp.server.auth.providers.jwt import JWTVerifier | |
| # This should work if PyJWT is available | |
| try: | |
| verifier = JWTVerifier(public_key="dummy-key") | |
| assert verifier.public_key == "dummy-key" | |
| assert verifier.algorithm == "RS256" | |
| except ImportError as e: | |
| # If PyJWT not available, should get helpful error | |
| assert "PyJWT is required" in str(e) | |