Block insecure HS* JWT verification with JWKS/public keys (#3430)

* 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
This commit is contained in:
Jeremiah Lowin 2026-03-07 12:20:48 -05:00 committed by GitHub
commit 5ed14650ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 58 additions and 13 deletions

View file

@ -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

View file

@ -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

View file

@ -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):

View file

@ -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
):