From 5ed14650ab68307040eb387c64bb3ff673c5d6f3 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 7 Mar 2026 12:20:48 -0500 Subject: [PATCH] Block insecure HS* JWT verification with JWKS/public keys (#3430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Block HS* JWT verification with public keys/JWKS 🤖 Generated with GPT-5.2-Codex * Fix ruff format violations 🤖 Generated with Claude Code * Handle bytes public_key in HS* algorithm PEM check --- src/fastmcp/server/auth/providers/jwt.py | 33 +++++++++++++++---- src/fastmcp/server/auth/providers/supabase.py | 6 ++-- tests/server/auth/providers/test_supabase.py | 6 ++-- tests/server/auth/test_jwt_provider.py | 26 +++++++++++++++ 4 files changed, 58 insertions(+), 13 deletions(-) diff --git a/src/fastmcp/server/auth/providers/jwt.py b/src/fastmcp/server/auth/providers/jwt.py index 6368df12c..45f4ac99e 100644 --- a/src/fastmcp/server/auth/providers/jwt.py +++ b/src/fastmcp/server/auth/providers/jwt.py @@ -139,6 +139,20 @@ class RSAKeyPair: return token_bytes.decode("utf-8") +def _looks_like_pem_public_key(key: str | bytes) -> bool: + """Return True when key text appears to be PEM-encoded asymmetric key material.""" + if isinstance(key, bytes): + key = key.decode("utf-8", errors="replace") + key_text = key.strip() + pem_markers = ( + "-----BEGIN PUBLIC KEY-----", + "-----BEGIN RSA PUBLIC KEY-----", + "-----BEGIN EC PUBLIC KEY-----", + "-----BEGIN CERTIFICATE-----", + ) + return any(marker in key_text for marker in pem_markers) + + class JWTVerifier(TokenVerifier): """ JWT token verifier supporting both asymmetric (RSA/ECDSA) and symmetric (HMAC) algorithms. @@ -161,7 +175,7 @@ class JWTVerifier(TokenVerifier): def __init__( self, *, - public_key: str | None = None, + public_key: str | bytes | None = None, jwks_uri: str | None = None, issuer: str | list[str] | None = None, audience: str | list[str] | None = None, @@ -225,11 +239,16 @@ class JWTVerifier(TokenVerifier): }: raise ValueError(f"Unsupported algorithm: {algorithm}.") - if jwks_uri and algorithm.startswith("HS"): - raise ValueError( - "HMAC algorithms (HS256/HS384/HS512) require a shared secret via " - "public_key and cannot be used with jwks_uri" - ) + if algorithm.startswith("HS"): + if jwks_uri: + raise ValueError( + "Symmetric HS* algorithms cannot be used with jwks_uri; " + "configure a shared secret via public_key instead." + ) + if public_key and _looks_like_pem_public_key(public_key): + raise ValueError( + "Symmetric HS* algorithms require a shared secret, not a public key." + ) # Parse scopes if provided as string parsed_required_scopes = ( @@ -257,7 +276,7 @@ class JWTVerifier(TokenVerifier): self._jwks_cache_time: float = 0 self._cache_ttl = 3600 # 1 hour - async def _get_verification_key(self, token: str) -> str: + async def _get_verification_key(self, token: str) -> str | bytes: """Get the verification key for the token.""" if self.public_key: return self.public_key diff --git a/src/fastmcp/server/auth/providers/supabase.py b/src/fastmcp/server/auth/providers/supabase.py index 0ebdd124f..527d701ee 100644 --- a/src/fastmcp/server/auth/providers/supabase.py +++ b/src/fastmcp/server/auth/providers/supabase.py @@ -34,7 +34,7 @@ class SupabaseProvider(RemoteAuthProvider): 1. Supabase Project Setup: - Create a Supabase project at https://supabase.com - Note your project URL (e.g., "https://abc123.supabase.co") - - Configure your JWT algorithm in Supabase Auth settings (HS256, RS256, or ES256) + - Configure your JWT algorithm in Supabase Auth settings (RS256 or ES256) - Asymmetric keys (RS256/ES256) are recommended for production 2. JWT Verification: @@ -74,7 +74,7 @@ class SupabaseProvider(RemoteAuthProvider): project_url: AnyHttpUrl | str, base_url: AnyHttpUrl | str, auth_route: str = "/auth/v1", - algorithm: Literal["HS256", "RS256", "ES256"] = "ES256", + algorithm: Literal["RS256", "ES256"] = "ES256", required_scopes: list[str] | None = None, scopes_supported: list[str] | None = None, resource_name: str | None = None, @@ -88,7 +88,7 @@ class SupabaseProvider(RemoteAuthProvider): base_url: Public URL of this FastMCP server auth_route: Supabase Auth route. Defaults to "/auth/v1". Can be customized for self-hosted Supabase Auth setups using custom routes. - algorithm: JWT signing algorithm (HS256, RS256, or ES256). Must match your + algorithm: JWT signing algorithm (RS256 or ES256). Must match your Supabase Auth configuration. Defaults to ES256. required_scopes: Optional list of scopes to require for all requests. Note: Supabase currently uses RLS policies for authorization. OAuth-level diff --git a/tests/server/auth/providers/test_supabase.py b/tests/server/auth/providers/test_supabase.py index 5d1ce06f9..daa2e4a01 100644 --- a/tests/server/auth/providers/test_supabase.py +++ b/tests/server/auth/providers/test_supabase.py @@ -106,13 +106,13 @@ class TestSupabaseProvider: assert isinstance(provider.token_verifier, JWTVerifier) assert provider.token_verifier.algorithm == algorithm - def test_algorithm_hs256_rejected(self): - """Test that HS256 is rejected with Supabase JWKS verification.""" + def test_algorithm_rejects_hs256(self): + """Test that HS256 is rejected for Supabase's JWKS-based verifier.""" with pytest.raises(ValueError, match="cannot be used with jwks_uri"): SupabaseProvider( project_url="https://abc123.supabase.co", base_url="https://myserver.com", - algorithm="HS256", + algorithm="HS256", # type: ignore[arg-type] ) def test_algorithm_default_es256(self): diff --git a/tests/server/auth/test_jwt_provider.py b/tests/server/auth/test_jwt_provider.py index 9512cc1dc..042df9af8 100644 --- a/tests/server/auth/test_jwt_provider.py +++ b/tests/server/auth/test_jwt_provider.py @@ -224,6 +224,32 @@ class TestSymmetricKeyJWT: ) assert provider.algorithm == algorithm + def test_symmetric_algorithm_rejects_jwks_uri(self): + """HS* algorithms must not be configured with JWKS/public key endpoints.""" + with pytest.raises(ValueError, match="cannot be used with jwks_uri"): + JWTVerifier( + jwks_uri="https://test.example.com/.well-known/jwks.json", + issuer="https://test.example.com", + algorithm="HS256", + ) + + def test_symmetric_algorithm_rejects_pem_public_key(self, rsa_key_pair: RSAKeyPair): + """HS* algorithms must use a shared secret, not PEM public key material.""" + with pytest.raises(ValueError, match="require a shared secret"): + JWTVerifier( + public_key=rsa_key_pair.public_key, + issuer="https://test.example.com", + algorithm="HS256", + ) + + def test_symmetric_algorithm_accepts_bytes_secret(self): + """HS* algorithms accept bytes secrets without TypeError.""" + verifier = JWTVerifier( + public_key=b"secret", + algorithm="HS256", + ) + assert verifier.algorithm == "HS256" + async def test_valid_symmetric_token_validation( self, symmetric_key_helper: SymmetricKeyHelper, symmetric_provider: JWTVerifier ):