Support Ed25519 Algorithm (#742)

Ed25519 is needed for Arcade AS. This required migrating from
`python-jose` to `joserfc`, because `python-jose` didn't seem to support
Ed25519
This commit is contained in:
Eric Gustin 2026-01-16 15:55:05 -08:00 committed by GitHub
parent a941eb7ffe
commit 28c1863ee3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 518 additions and 231 deletions

View file

@ -4,11 +4,16 @@ JWKS-based token validator for MCP Resource Servers.
Implements OAuth 2.1 Resource Server token validation using JWT with JWKS.
"""
import binascii
import time
from typing import Any, cast
import httpx
from jose import jwk, jwt
from joserfc import jws, jwt
from joserfc.errors import JoseError
from joserfc.jwk import KeySet, KeySetSerialization
from joserfc.jws import JWSRegistry
from joserfc.registry import HeaderParameter
from arcade_mcp_server.resource_server.base import (
AccessTokenValidationOptions,
@ -19,19 +24,36 @@ from arcade_mcp_server.resource_server.base import (
TokenExpiredError,
)
# Note: Only asymmetric algorithms supported
SUPPORTED_ALGORITHMS = {
# RSA
"RS256",
"RS384",
"RS512",
# ECDSA
"ES256",
"ES384",
"ES512",
# RSA-PSS
"PS256",
"PS384",
"PS512",
# EdDSA
"Ed25519",
"EdDSA",
}
# EdDSA algorithm aliases
EDDSA_ALGORITHMS = {"Ed25519", "EdDSA"}
# Custom JWS registry that allows additional header params beyond joserfc's default
_ACCESS_TOKEN_REGISTRY = JWSRegistry(
header_registry={
"iss": HeaderParameter("Issuer", "str"),
"aud": HeaderParameter("Audience", "str"),
},
algorithms=list(SUPPORTED_ALGORITHMS),
)
class JWKSTokenValidator(ResourceServerValidator):
"""JWKS-based JWT token validator for simple, explicit token validation.
@ -114,6 +136,31 @@ class JWKSTokenValidator(ResourceServerValidator):
self._jwks_cache: dict[str, Any] | None = None
self._cache_timestamp: float = 0
def _normalize_algorithm(self, alg: str) -> str:
"""Normalize algorithm name for comparison.
EdDSA has multiple names (EdDSA, Ed25519) that should be treated as equivalent.
Args:
alg: Algorithm name
Returns:
Normalized algorithm name
"""
return "Ed25519" if alg in EDDSA_ALGORITHMS else alg
def _algorithms_match(self, alg1: str, alg2: str) -> bool:
"""Check if two algorithm names match (considering EdDSA aliases).
Args:
alg1: First algorithm name
alg2: Second algorithm name
Returns:
True if algorithms match
"""
return self._normalize_algorithm(alg1) == self._normalize_algorithm(alg2)
async def _fetch_jwks(self) -> dict[str, Any]:
"""Fetch JWKS with caching.
@ -139,6 +186,25 @@ class JWKSTokenValidator(ResourceServerValidator):
else:
return self._jwks_cache
def _get_headers_without_verification(self, token: str) -> dict[str, Any]:
"""Extract header from JWT without verification.
Args:
token: JWT token string
Returns:
Header dictionary containing 'alg', 'kid', etc.
Raises:
InvalidTokenError: If token format is invalid
"""
try:
# Use joserfc's extract_compact to parse JWT without verification
obj = jws.extract_compact(token.encode())
return obj.headers()
except (JoseError, ValueError, binascii.Error) as e:
raise InvalidTokenError(f"Invalid JWT format: {e}") from e
def _find_signing_key(self, jwks: dict[str, Any], token: str) -> Any:
"""Find the signing key from JWKS that matches the token's kid.
@ -147,74 +213,92 @@ class JWKSTokenValidator(ResourceServerValidator):
token: JWT token
Returns:
Signing key in PEM format
Key object from joserfc KeySet
Raises:
InvalidTokenError: If no matching key found or algorithm mismatch
"""
unverified_header = jwt.get_unverified_header(token)
kid = unverified_header.get("kid")
token_alg = unverified_header.get("alg")
header = self._get_headers_without_verification(token)
kid = header.get("kid")
token_alg = header.get("alg")
# Validate token algorithm matches configuration (prevent algorithm confusion)
if token_alg and token_alg != self.algorithm:
if token_alg and not self._algorithms_match(token_alg, self.algorithm):
raise InvalidTokenError(
f"Token algorithm '{token_alg}' doesn't match "
f"configured algorithm '{self.algorithm}'"
)
for key_data in jwks.get("keys", []):
if key_data.get("kid") == kid:
key_alg = key_data.get("alg")
try:
key_set = KeySet.import_key_set(cast(KeySetSerialization, jwks))
except Exception as e:
raise InvalidTokenError(f"Failed to import JWKS: {e}") from e
if key_alg and key_alg != self.algorithm:
# Find key by kid
for key in key_set.keys:
if key.kid == kid:
key_alg = key.alg
if key_alg and not self._algorithms_match(key_alg, self.algorithm):
raise InvalidTokenError(
f"Key algorithm '{key_alg}' doesn't match "
f"configured algorithm '{self.algorithm}'"
)
key_obj = jwk.construct(key_data, algorithm=self.algorithm)
return key_obj.to_pem().decode("utf-8")
return key
raise InvalidTokenError("No matching key found in JWKS")
def _decode_token(self, token: str, signing_key: str) -> dict[str, Any]:
def _decode_token(self, token: str, signing_key: Any) -> dict[str, Any]:
"""Decode and verify the provided JWT token.
Uses jwt.decode for signature verification and payload parsing, then
performs time-based claims validation manually for leeway handling
Args:
token: JWT token
signing_key: Public key in PEM format
signing_key: Key object from joserfc
Returns:
Decoded token claims
Raises:
jwt.ExpiredSignatureError: Token has expired
jwt.JWTClaimsError: Token claims validation failed (audience/issuer mismatch)
jwt.JWTError: Token is invalid
TokenExpiredError: Token has expired
InvalidTokenError: Token validation failed
"""
decode_options = {
"verify_signature": True, # Always verify signature. Cannot be disabled.
"verify_exp": self.validation_options.verify_exp,
"verify_iat": self.validation_options.verify_iat,
"verify_nbf": self.validation_options.verify_nbf,
"verify_aud": False, # Manual validation for multi-audience support
"verify_iss": False, # Manual validation for multi-issuer support
"leeway": self.validation_options.leeway,
}
if self.algorithm in EDDSA_ALGORITHMS:
algorithms = list(EDDSA_ALGORITHMS)
else:
algorithms = [self.algorithm]
# Decode token once without aud/iss validation
decoded = cast(
dict[str, Any],
jwt.decode(
token,
signing_key,
algorithms=[self.algorithm],
options=decode_options,
),
)
# First, verify signature & decode payload
try:
result = jwt.decode(
token, signing_key, algorithms=algorithms, registry=_ACCESS_TOKEN_REGISTRY
)
except JoseError as e:
raise InvalidTokenError(f"Token signature verification failed: {e}") from e
# Manually validate issuer (if flag is enabled)
decoded = result.claims
# Validate time based claims with leeway support
current_time = int(time.time())
leeway = self.validation_options.leeway
if self.validation_options.verify_exp:
exp = decoded.get("exp")
if exp is not None and exp + leeway < current_time:
raise TokenExpiredError("Token has expired")
if self.validation_options.verify_iat:
iat = decoded.get("iat")
if iat is not None and iat - leeway > current_time:
raise InvalidTokenError("Token issued in the future")
if self.validation_options.verify_nbf:
nbf = decoded.get("nbf")
if nbf is not None and nbf - leeway > current_time:
raise InvalidTokenError("Token not yet valid")
# Manually validate issuer (if enabled)
if self.validation_options.verify_iss:
token_iss = decoded.get("iss")
if isinstance(self.issuer, list):
@ -313,12 +397,6 @@ class JWKSTokenValidator(ResourceServerValidator):
claims=decoded,
)
except jwt.ExpiredSignatureError as e:
raise TokenExpiredError("Token has expired") from e
except jwt.JWTClaimsError as e:
raise InvalidTokenError(f"Token claims validation failed: {e}") from e
except jwt.JWTError as e:
raise InvalidTokenError(f"Invalid token: {e}") from e
except (InvalidTokenError, TokenExpiredError):
raise
except Exception as e:

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "arcade-mcp-server"
version = "1.14.2"
version = "1.15.0"
description = "Model Context Protocol (MCP) server framework for Arcade.dev"
readme = "README.md"
authors = [{ name = "Arcade.dev" }]
@ -34,7 +34,7 @@ dependencies = [
"anyio>=4.0.0",
"python-dotenv>=1.0.0",
"pydantic-settings>=2.10.1",
"python-jose[cryptography]>=3.3.0,<4.0.0",
"joserfc>=1.5.0",
"httpx>=0.27.0,<1.0.0",
]

View file

@ -19,7 +19,9 @@ from arcade_mcp_server.resource_server.middleware import ResourceServerMiddlewar
from arcade_mcp_server.worker import create_arcade_mcp
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from jose import jwt
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from joserfc import jwt
from joserfc.jwk import OKPKey, RSAKey
# Test fixtures
@ -49,11 +51,23 @@ def rsa_keypair():
return private_key, public_key
@pytest.fixture
def rsa_joserfc_key(rsa_keypair):
"""Generate joserfc RSAKey from keypair."""
private_key, _ = rsa_keypair
pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
return RSAKey.import_key(pem)
@pytest.fixture
def serialized_private_key(rsa_keypair):
"""Generate private key as PEM format for testing."""
private_key, _ = rsa_keypair
# Serialize private key to PEM format for python-jose
# Serialize private key to PEM format
pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
@ -94,11 +108,9 @@ def jwks_data(rsa_keypair):
@pytest.fixture
def valid_jwt_token(rsa_keypair):
def valid_jwt_token(rsa_joserfc_key):
"""Generate valid JWT token for testing."""
private_key, _ = rsa_keypair
payload = {
claims = {
"sub": "user123",
"email": "user@example.com",
"iss": "https://auth.example.com",
@ -107,22 +119,16 @@ def valid_jwt_token(rsa_keypair):
"iat": int(time.time()),
}
token = jwt.encode(
payload,
private_key,
algorithm="RS256",
headers={"kid": "test-key-1"},
)
header = {"alg": "RS256", "kid": "test-key-1"}
token = jwt.encode(header, claims, rsa_joserfc_key)
return token
@pytest.fixture
def expired_jwt_token(rsa_keypair):
def expired_jwt_token(rsa_joserfc_key):
"""Generate expired JWT token for testing."""
private_key, _ = rsa_keypair
payload = {
claims = {
"sub": "user123",
"email": "user@example.com",
"iss": "https://auth.example.com",
@ -131,12 +137,92 @@ def expired_jwt_token(rsa_keypair):
"iat": int(time.time()) - 7200,
}
token = jwt.encode(
payload,
private_key,
algorithm="RS256",
headers={"kid": "test-key-1"},
header = {"alg": "RS256", "kid": "test-key-1"}
token = jwt.encode(header, claims, rsa_joserfc_key)
return token
# Ed25519 fixtures
@pytest.fixture
def ed25519_keypair():
"""Generate Ed25519 key pair for testing."""
private_key = Ed25519PrivateKey.generate()
public_key = private_key.public_key()
return private_key, public_key
@pytest.fixture
def ed25519_joserfc_key(ed25519_keypair):
"""Generate joserfc OKPKey from Ed25519 keypair."""
private_key, _ = ed25519_keypair
pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
return OKPKey.import_key(pem)
@pytest.fixture
def ed25519_jwks_data(ed25519_keypair):
"""Generate Ed25519 JWKS data for testing."""
_, public_key = ed25519_keypair
# Get the raw public key bytes
public_bytes = public_key.public_bytes_raw()
# Base64url encode the public key
x_b64 = base64.urlsafe_b64encode(public_bytes).decode("utf-8").rstrip("=")
return {
"keys": [
{
"kty": "OKP",
"kid": "ed25519-key-1",
"use": "sig",
"alg": "Ed25519",
"crv": "Ed25519",
"x": x_b64,
}
]
}
@pytest.fixture
def valid_ed25519_token(ed25519_joserfc_key):
"""Generate valid Ed25519 JWT token for testing."""
claims = {
"sub": "user456",
"email": "ed25519user@example.com",
"iss": "https://cloud.arcade.dev/oauth2",
"aud": "urn:arcade:mcp",
"exp": int(time.time()) + 3600,
"iat": int(time.time()),
}
header = {"alg": "Ed25519", "kid": "ed25519-key-1"}
# Ed25519 is not in joserfc's recommended algorithms, so we must explicitly allow it
token = jwt.encode(header, claims, ed25519_joserfc_key, algorithms=["Ed25519"])
return token
@pytest.fixture
def expired_ed25519_token(ed25519_joserfc_key):
"""Generate expired Ed25519 JWT token for testing."""
claims = {
"sub": "user456",
"email": "ed25519user@example.com",
"iss": "https://cloud.arcade.dev/oauth2",
"aud": "urn:arcade:mcp",
"exp": int(time.time()) - 3600,
"iat": int(time.time()) - 7200,
}
header = {"alg": "Ed25519", "kid": "ed25519-key-1"}
# Ed25519 is not in joserfc's recommended algorithms, so we must explicitly allow it
token = jwt.encode(header, claims, ed25519_joserfc_key, algorithms=["Ed25519"])
return token
@ -185,9 +271,9 @@ class TestJWKSTokenValidator:
await validator.validate_token(expired_jwt_token)
@pytest.mark.asyncio
async def test_validate_wrong_audience(self, serialized_private_key, jwks_data):
async def test_validate_wrong_audience(self, rsa_joserfc_key, jwks_data):
"""Test validating token with wrong audience."""
payload = {
claims = {
"sub": "user123",
"iss": "https://auth.example.com",
"aud": "https://wrong-server.com", # Wrong audience
@ -195,12 +281,8 @@ class TestJWKSTokenValidator:
"iat": int(time.time()),
}
token = jwt.encode(
payload,
serialized_private_key,
algorithm="RS256",
headers={"kid": "test-key-1"},
)
header = {"alg": "RS256", "kid": "test-key-1"}
token = jwt.encode(header, claims, rsa_joserfc_key)
with patch("httpx.AsyncClient.get") as mock_get:
mock_response = Mock()
@ -218,9 +300,9 @@ class TestJWKSTokenValidator:
await validator.validate_token(token)
@pytest.mark.asyncio
async def test_validate_wrong_issuer(self, serialized_private_key, jwks_data):
async def test_validate_wrong_issuer(self, rsa_joserfc_key, jwks_data):
"""Test validating token with wrong issuer."""
payload = {
claims = {
"sub": "user123",
"iss": "https://wrong-issuer.com", # Wrong issuer
"aud": "https://mcp.example.com/mcp",
@ -228,12 +310,8 @@ class TestJWKSTokenValidator:
"iat": int(time.time()),
}
token = jwt.encode(
payload,
serialized_private_key,
algorithm="RS256",
headers={"kid": "test-key-1"},
)
header = {"alg": "RS256", "kid": "test-key-1"}
token = jwt.encode(header, claims, rsa_joserfc_key)
with patch("httpx.AsyncClient.get") as mock_get:
mock_response = Mock()
@ -251,21 +329,17 @@ class TestJWKSTokenValidator:
await validator.validate_token(token)
@pytest.mark.asyncio
async def test_validate_missing_sub_claim(self, serialized_private_key, jwks_data):
async def test_validate_missing_sub_claim(self, rsa_joserfc_key, jwks_data):
"""Test validating token without sub claim."""
payload = {
claims = {
"iss": "https://auth.example.com",
"aud": "https://mcp.example.com/mcp",
"exp": int(time.time()) + 3600,
"iat": int(time.time()),
}
token = jwt.encode(
payload,
serialized_private_key,
algorithm="RS256",
headers={"kid": "test-key-1"},
)
header = {"alg": "RS256", "kid": "test-key-1"}
token = jwt.encode(header, claims, rsa_joserfc_key)
with patch("httpx.AsyncClient.get") as mock_get:
mock_response = Mock()
@ -307,11 +381,9 @@ class TestJWKSTokenValidator:
assert mock_get.call_count == 1
@pytest.mark.asyncio
async def test_validate_multiple_audiences_single_token_aud(
self, serialized_private_key, jwks_data
):
async def test_validate_multiple_audiences_single_token_aud(self, rsa_joserfc_key, jwks_data):
"""Test validator with multiple audiences accepts token with matching single aud."""
payload = {
claims = {
"sub": "user123",
"iss": "https://auth.example.com",
"aud": "https://old-mcp.example.com", # Matches first audience
@ -319,12 +391,8 @@ class TestJWKSTokenValidator:
"iat": int(time.time()),
}
token = jwt.encode(
payload,
serialized_private_key,
algorithm="RS256",
headers={"kid": "test-key-1"},
)
header = {"alg": "RS256", "kid": "test-key-1"}
token = jwt.encode(header, claims, rsa_joserfc_key)
with patch("httpx.AsyncClient.get") as mock_get:
mock_response = Mock()
@ -342,12 +410,10 @@ class TestJWKSTokenValidator:
assert user.user_id == "user123"
@pytest.mark.asyncio
async def test_validate_multiple_audiences_list_token_aud(
self, serialized_private_key, jwks_data
):
async def test_validate_multiple_audiences_list_token_aud(self, rsa_joserfc_key, jwks_data):
"""Test validator with multiple audiences accepts token with list aud."""
# Token with list of audiences where one matches the validator's accepted audiences
payload = {
claims = {
"sub": "user123",
"iss": "https://auth.example.com",
"aud": ["https://api1.com", "https://new-mcp.example.com"], # Second matches
@ -355,12 +421,8 @@ class TestJWKSTokenValidator:
"iat": int(time.time()),
}
token = jwt.encode(
payload,
serialized_private_key,
algorithm="RS256",
headers={"kid": "test-key-1"},
)
header = {"alg": "RS256", "kid": "test-key-1"}
token = jwt.encode(header, claims, rsa_joserfc_key)
with patch("httpx.AsyncClient.get") as mock_get:
mock_response = Mock()
@ -378,10 +440,10 @@ class TestJWKSTokenValidator:
assert user.user_id == "user123"
@pytest.mark.asyncio
async def test_validate_multiple_audiences_no_match(self, serialized_private_key, jwks_data):
async def test_validate_multiple_audiences_no_match(self, rsa_joserfc_key, jwks_data):
"""Test validator with multiple audiences rejects token with non-matching aud."""
# Token with audience that doesn't match any of validator's accepted audiences
payload = {
claims = {
"sub": "user123",
"iss": "https://auth.example.com",
"aud": "https://different-server.com", # Doesn't match
@ -389,12 +451,8 @@ class TestJWKSTokenValidator:
"iat": int(time.time()),
}
token = jwt.encode(
payload,
serialized_private_key,
algorithm="RS256",
headers={"kid": "test-key-1"},
)
header = {"alg": "RS256", "kid": "test-key-1"}
token = jwt.encode(header, claims, rsa_joserfc_key)
with patch("httpx.AsyncClient.get") as mock_get:
mock_response = Mock()
@ -412,12 +470,10 @@ class TestJWKSTokenValidator:
await validator.validate_token(token)
@pytest.mark.asyncio
async def test_validate_single_audience_with_list_token_aud(
self, serialized_private_key, jwks_data
):
async def test_validate_single_audience_with_list_token_aud(self, rsa_joserfc_key, jwks_data):
"""Test validator with single audience accepts token with list aud containing match."""
# Token with list of audiences where one matches validator's single audience
payload = {
claims = {
"sub": "user123",
"iss": "https://auth.example.com",
"aud": ["https://api1.com", "https://mcp.example.com/mcp", "https://api2.com"],
@ -425,12 +481,8 @@ class TestJWKSTokenValidator:
"iat": int(time.time()),
}
token = jwt.encode(
payload,
serialized_private_key,
algorithm="RS256",
headers={"kid": "test-key-1"},
)
header = {"alg": "RS256", "kid": "test-key-1"}
token = jwt.encode(header, claims, rsa_joserfc_key)
with patch("httpx.AsyncClient.get") as mock_get:
mock_response = Mock()
@ -448,10 +500,10 @@ class TestJWKSTokenValidator:
assert user.user_id == "user123"
@pytest.mark.asyncio
async def test_validate_multiple_issuers_efficient(self, serialized_private_key, jwks_data):
async def test_validate_multiple_issuers_efficient(self, rsa_joserfc_key, jwks_data):
"""Test that multi-issuer validation is efficient (single decode)."""
# Token from second issuer in list
payload = {
claims = {
"sub": "user123",
"iss": "https://auth2.example.com", # Second in list
"aud": "https://mcp.example.com/mcp",
@ -459,12 +511,8 @@ class TestJWKSTokenValidator:
"iat": int(time.time()),
}
token = jwt.encode(
payload,
serialized_private_key,
algorithm="RS256",
headers={"kid": "test-key-1"},
)
header = {"alg": "RS256", "kid": "test-key-1"}
token = jwt.encode(header, claims, rsa_joserfc_key)
with patch("httpx.AsyncClient.get") as mock_get:
mock_response = Mock()
@ -472,9 +520,9 @@ class TestJWKSTokenValidator:
mock_response.raise_for_status = Mock()
mock_get.return_value = mock_response
# Patch jwt.decode to count calls
with patch(
"arcade_mcp_server.resource_server.validators.jwks.jwt.decode", wraps=jwt.decode
"arcade_mcp_server.resource_server.validators.jwks.jwt.decode",
wraps=jwt.decode,
) as mock_decode:
validator = JWKSTokenValidator(
jwks_uri="https://auth.example.com/.well-known/jwks.json",
@ -493,9 +541,9 @@ class TestJWKSTokenValidator:
assert mock_decode.call_count == 1
@pytest.mark.asyncio
async def test_validate_nbf_claim_before_time(self, serialized_private_key, jwks_data):
async def test_validate_nbf_claim_before_time(self, rsa_joserfc_key, jwks_data):
"""Test that token with nbf claim in the future is rejected."""
payload = {
claims = {
"sub": "user123",
"iss": "https://auth.example.com",
"aud": "https://mcp.example.com/mcp",
@ -504,12 +552,8 @@ class TestJWKSTokenValidator:
"nbf": int(time.time()) + 3600, # Not valid for 1 hour
}
token = jwt.encode(
payload,
serialized_private_key,
algorithm="RS256",
headers={"kid": "test-key-1"},
)
header = {"alg": "RS256", "kid": "test-key-1"}
token = jwt.encode(header, claims, rsa_joserfc_key)
with patch("httpx.AsyncClient.get") as mock_get:
mock_response = Mock()
@ -528,9 +572,9 @@ class TestJWKSTokenValidator:
await validator.validate_token(token)
@pytest.mark.asyncio
async def test_validate_nbf_claim_disabled(self, serialized_private_key, jwks_data):
async def test_validate_nbf_claim_disabled(self, rsa_joserfc_key, jwks_data):
"""Test that token with nbf in future is accepted when verify_nbf=False."""
payload = {
claims = {
"sub": "user123",
"iss": "https://auth.example.com",
"aud": "https://mcp.example.com/mcp",
@ -539,12 +583,8 @@ class TestJWKSTokenValidator:
"nbf": int(time.time()) + 3600, # Not valid for 1 hour
}
token = jwt.encode(
payload,
serialized_private_key,
algorithm="RS256",
headers={"kid": "test-key-1"},
)
header = {"alg": "RS256", "kid": "test-key-1"}
token = jwt.encode(header, claims, rsa_joserfc_key)
with patch("httpx.AsyncClient.get") as mock_get:
mock_response = Mock()
@ -564,10 +604,10 @@ class TestJWKSTokenValidator:
assert user.user_id == "user123"
@pytest.mark.asyncio
async def test_validate_with_leeway(self, serialized_private_key, jwks_data):
async def test_validate_with_leeway(self, rsa_joserfc_key, jwks_data):
"""Test that leeway allows slightly expired tokens."""
# Token expired 30 seconds ago
payload = {
claims = {
"sub": "user123",
"iss": "https://auth.example.com",
"aud": "https://mcp.example.com/mcp",
@ -575,12 +615,8 @@ class TestJWKSTokenValidator:
"iat": int(time.time()) - 3600,
}
token = jwt.encode(
payload,
serialized_private_key,
algorithm="RS256",
headers={"kid": "test-key-1"},
)
header = {"alg": "RS256", "kid": "test-key-1"}
token = jwt.encode(header, claims, rsa_joserfc_key)
with patch("httpx.AsyncClient.get") as mock_get:
mock_response = Mock()
@ -600,6 +636,209 @@ class TestJWKSTokenValidator:
assert user.user_id == "user123"
class TestJWKSTokenValidatorEd25519:
"""Tests for JWKSTokenValidator with Ed25519 algorithm."""
@pytest.mark.asyncio
async def test_validate_valid_ed25519_token(self, valid_ed25519_token, ed25519_jwks_data):
"""Test validating a valid Ed25519 JWT token."""
with patch("httpx.AsyncClient.get") as mock_get:
mock_response = Mock()
mock_response.json.return_value = ed25519_jwks_data
mock_response.raise_for_status = Mock()
mock_get.return_value = mock_response
validator = JWKSTokenValidator(
jwks_uri="https://cloud.arcade.dev/.well-known/jwks/oauth2",
issuer="https://cloud.arcade.dev/oauth2",
audience="urn:arcade:mcp",
algorithm="Ed25519",
)
user = await validator.validate_token(valid_ed25519_token)
assert isinstance(user, ResourceOwner)
assert user.user_id == "user456"
assert user.email == "ed25519user@example.com"
assert user.claims["iss"] == "https://cloud.arcade.dev/oauth2"
@pytest.mark.asyncio
async def test_validate_expired_ed25519_token(self, expired_ed25519_token, ed25519_jwks_data):
"""Test validating an expired Ed25519 JWT token."""
with patch("httpx.AsyncClient.get") as mock_get:
mock_response = Mock()
mock_response.json.return_value = ed25519_jwks_data
mock_response.raise_for_status = Mock()
mock_get.return_value = mock_response
validator = JWKSTokenValidator(
jwks_uri="https://cloud.arcade.dev/.well-known/jwks/oauth2",
issuer="https://cloud.arcade.dev/oauth2",
audience="urn:arcade:mcp",
algorithm="Ed25519",
)
with pytest.raises(TokenExpiredError):
await validator.validate_token(expired_ed25519_token)
@pytest.mark.asyncio
async def test_ed25519_algorithm_mismatch(self, valid_jwt_token, ed25519_jwks_data):
"""Test that RS256 token is rejected when validator expects Ed25519."""
with patch("httpx.AsyncClient.get") as mock_get:
mock_response = Mock()
mock_response.json.return_value = ed25519_jwks_data
mock_response.raise_for_status = Mock()
mock_get.return_value = mock_response
validator = JWKSTokenValidator(
jwks_uri="https://cloud.arcade.dev/.well-known/jwks/oauth2",
issuer="https://auth.example.com",
audience="https://mcp.example.com/mcp",
algorithm="Ed25519",
)
# RS256 token should be rejected when Ed25519 is expected
with pytest.raises(InvalidTokenError, match="algorithm"):
await validator.validate_token(valid_jwt_token)
@pytest.mark.asyncio
async def test_ed25519_wrong_audience(self, ed25519_joserfc_key, ed25519_jwks_data):
"""Test Ed25519 token with wrong audience is rejected."""
claims = {
"sub": "user456",
"iss": "https://cloud.arcade.dev/oauth2",
"aud": "wrong:audience",
"exp": int(time.time()) + 3600,
"iat": int(time.time()),
}
header = {"alg": "Ed25519", "kid": "ed25519-key-1"}
token = jwt.encode(header, claims, ed25519_joserfc_key, algorithms=["Ed25519"])
with patch("httpx.AsyncClient.get") as mock_get:
mock_response = Mock()
mock_response.json.return_value = ed25519_jwks_data
mock_response.raise_for_status = Mock()
mock_get.return_value = mock_response
validator = JWKSTokenValidator(
jwks_uri="https://cloud.arcade.dev/.well-known/jwks/oauth2",
issuer="https://cloud.arcade.dev/oauth2",
audience="urn:arcade:mcp",
algorithm="Ed25519",
)
with pytest.raises(InvalidTokenError, match="audience"):
await validator.validate_token(token)
@pytest.mark.asyncio
async def test_eddsa_algorithm_alias(self, ed25519_joserfc_key, ed25519_jwks_data):
"""Test that EdDSA algorithm alias works for Ed25519."""
claims = {
"sub": "user456",
"email": "ed25519user@example.com",
"iss": "https://cloud.arcade.dev/oauth2",
"aud": "urn:arcade:mcp",
"exp": int(time.time()) + 3600,
"iat": int(time.time()),
}
header = {"alg": "Ed25519", "kid": "ed25519-key-1"}
token = jwt.encode(header, claims, ed25519_joserfc_key, algorithms=["Ed25519"])
with patch("httpx.AsyncClient.get") as mock_get:
mock_response = Mock()
mock_response.json.return_value = ed25519_jwks_data
mock_response.raise_for_status = Mock()
mock_get.return_value = mock_response
# Use EdDSA alias
validator = JWKSTokenValidator(
jwks_uri="https://cloud.arcade.dev/.well-known/jwks/oauth2",
issuer="https://cloud.arcade.dev/oauth2",
audience="urn:arcade:mcp",
algorithm="EdDSA", # Using EdDSA alias
)
user = await validator.validate_token(token)
assert user.user_id == "user456"
def test_ed25519_supported_algorithm(self):
"""Test that Ed25519 is in supported algorithms."""
# Should not raise
validator = JWKSTokenValidator(
jwks_uri="https://example.com/jwks",
issuer="https://example.com",
audience="https://example.com",
algorithm="Ed25519",
)
assert validator.algorithm == "Ed25519"
def test_eddsa_supported_algorithm(self):
"""Test that EdDSA is in supported algorithms."""
# Should not raise
validator = JWKSTokenValidator(
jwks_uri="https://example.com/jwks",
issuer="https://example.com",
audience="https://example.com",
algorithm="EdDSA",
)
assert validator.algorithm == "EdDSA"
class TestArcadeASConfiguration:
"""Tests for Arcade AS configuration."""
@pytest.mark.asyncio
async def test_arcade_as_config(self, valid_ed25519_token, ed25519_jwks_data):
"""Test configuration matching Arcade AS."""
with patch("httpx.AsyncClient.get") as mock_get:
mock_response = Mock()
mock_response.json.return_value = ed25519_jwks_data
mock_response.raise_for_status = Mock()
mock_get.return_value = mock_response
# Configuration matching Arcade AS
resource_server_auth = ResourceServerAuth(
canonical_url="https://gateway-manager.arcade.dev/mcp",
authorization_servers=[
AuthorizationServerEntry(
authorization_server_url="https://cloud.arcade.dev/oauth2",
issuer="https://cloud.arcade.dev/oauth2",
jwks_uri="https://cloud.arcade.dev/.well-known/jwks/oauth2",
algorithm="Ed25519",
expected_audiences=[
"urn:arcade:mcp",
"https://gateway-manager.arcade.dev/mcp",
],
)
],
)
user = await resource_server_auth.validate_token(valid_ed25519_token)
assert user.user_id == "user456"
assert user.email == "ed25519user@example.com"
def test_arcade_as_metadata(self):
"""Test OAuth metadata for Arcade AS configuration."""
resource_server_auth = ResourceServerAuth(
canonical_url="https://gateway-manager.arcade.dev/mcp",
authorization_servers=[
AuthorizationServerEntry(
authorization_server_url="https://cloud.arcade.dev/oauth2",
issuer="https://cloud.arcade.dev/oauth2",
jwks_uri="https://cloud.arcade.dev/.well-known/jwks/oauth2",
algorithm="Ed25519",
expected_audiences=["urn:arcade:mcp"],
)
],
)
metadata = resource_server_auth.get_resource_metadata()
assert metadata["resource"] == "https://gateway-manager.arcade.dev/mcp"
assert metadata["authorization_servers"] == ["https://cloud.arcade.dev/oauth2"]
# ResourceServerAuth Tests
class TestResourceServerAuth:
"""Tests for ResourceServerAuth class."""
@ -639,12 +878,10 @@ class TestResourceServerAuth:
assert metadata["bearer_methods_supported"] == ["header"]
@pytest.mark.asyncio
async def test_expected_audiences_override(self, rsa_keypair, jwks_data):
async def test_expected_audiences_override(self, rsa_keypair, jwks_data, rsa_joserfc_key):
"""Test that expected_audiences overrides canonical_url for audience validation."""
private_key, _ = rsa_keypair
# Token with custom audience
payload = {
claims = {
"sub": "user123",
"iss": "https://auth.example.com",
"aud": "my-authkit-client-id",
@ -652,12 +889,8 @@ class TestResourceServerAuth:
"iat": int(time.time()),
}
token = jwt.encode(
payload,
private_key,
algorithm="RS256",
headers={"kid": "test-key-1"},
)
header = {"alg": "RS256", "kid": "test-key-1"}
token = jwt.encode(header, claims, rsa_joserfc_key)
with patch("httpx.AsyncClient.get") as mock_get:
mock_response = Mock()
@ -681,12 +914,12 @@ class TestResourceServerAuth:
assert user.user_id == "user123"
@pytest.mark.asyncio
async def test_expected_audiences_multiple_values(self, rsa_keypair, jwks_data):
async def test_expected_audiences_multiple_values(
self, rsa_keypair, jwks_data, rsa_joserfc_key
):
"""Test that multiple expected_audiences work correctly."""
private_key, _ = rsa_keypair
# Token with one of the expected audiences
payload = {
claims = {
"sub": "user123",
"iss": "https://auth.example.com",
"aud": "secondary-client-id",
@ -694,12 +927,8 @@ class TestResourceServerAuth:
"iat": int(time.time()),
}
token = jwt.encode(
payload,
private_key,
algorithm="RS256",
headers={"kid": "test-key-1"},
)
header = {"alg": "RS256", "kid": "test-key-1"}
token = jwt.encode(header, claims, rsa_joserfc_key)
with patch("httpx.AsyncClient.get") as mock_get:
mock_response = Mock()
@ -727,11 +956,11 @@ class TestResourceServerAuth:
assert user.user_id == "user123"
@pytest.mark.asyncio
async def test_expected_audiences_defaults_to_canonical_url(self, rsa_keypair, jwks_data):
async def test_expected_audiences_defaults_to_canonical_url(
self, rsa_keypair, jwks_data, rsa_joserfc_key
):
"""Test that without expected_audiences, canonical_url is used for audience validation."""
private_key, _ = rsa_keypair
payload = {
claims = {
"sub": "user123",
"iss": "https://auth.example.com",
"aud": "https://mcp.example.com/mcp",
@ -739,12 +968,8 @@ class TestResourceServerAuth:
"iat": int(time.time()),
}
token = jwt.encode(
payload,
private_key,
algorithm="RS256",
headers={"kid": "test-key-1"},
)
header = {"alg": "RS256", "kid": "test-key-1"}
token = jwt.encode(header, claims, rsa_joserfc_key)
with patch("httpx.AsyncClient.get") as mock_get:
mock_response = Mock()
@ -767,11 +992,11 @@ class TestResourceServerAuth:
assert user.user_id == "user123"
@pytest.mark.asyncio
async def test_expected_audiences_wrong_audience_rejected(self, rsa_keypair, jwks_data):
async def test_expected_audiences_wrong_audience_rejected(
self, rsa_keypair, jwks_data, rsa_joserfc_key
):
"""Test that tokens with wrong audience are rejected even with expected_audiences."""
private_key, _ = rsa_keypair
payload = {
claims = {
"sub": "user123",
"iss": "https://auth.example.com",
"aud": "wrong-client-id", # Not in expected_audiences list
@ -779,12 +1004,8 @@ class TestResourceServerAuth:
"iat": int(time.time()),
}
token = jwt.encode(
payload,
private_key,
algorithm="RS256",
headers={"kid": "test-key-1"},
)
header = {"alg": "RS256", "kid": "test-key-1"}
token = jwt.encode(header, claims, rsa_joserfc_key)
with patch("httpx.AsyncClient.get") as mock_get:
mock_response = Mock()
@ -1085,11 +1306,11 @@ class TestMultipleAuthorizationServers:
assert user.email == "user@example.com"
@pytest.mark.asyncio
async def test_resource_server_multiple_as_different_jwks(self, rsa_keypair, jwks_data):
async def test_resource_server_multiple_as_different_jwks(
self, rsa_keypair, jwks_data, rsa_joserfc_key
):
"""Test multiple AS with different JWKS (multi-IdP)."""
private_key, _ = rsa_keypair
payload1 = {
claims1 = {
"sub": "user123",
"email": "user@workos.com",
"iss": "https://workos.authkit.app",
@ -1097,14 +1318,10 @@ class TestMultipleAuthorizationServers:
"exp": int(time.time()) + 3600,
"iat": int(time.time()),
}
token1 = jwt.encode(
payload1,
private_key,
algorithm="RS256",
headers={"kid": "test-key-1"},
)
header1 = {"alg": "RS256", "kid": "test-key-1"}
token1 = jwt.encode(header1, claims1, rsa_joserfc_key)
payload2 = {
claims2 = {
"sub": "user456",
"email": "user@keycloak.com",
"iss": "http://localhost:8080/realms/mcp-test",
@ -1112,12 +1329,8 @@ class TestMultipleAuthorizationServers:
"exp": int(time.time()) + 3600,
"iat": int(time.time()),
}
token2 = jwt.encode(
payload2,
private_key,
algorithm="RS256",
headers={"kid": "test-key-1"},
)
header2 = {"alg": "RS256", "kid": "test-key-1"}
token2 = jwt.encode(header2, claims2, rsa_joserfc_key)
with patch("httpx.AsyncClient.get") as mock_get:
mock_response = Mock()
@ -1159,11 +1372,11 @@ class TestMultipleAuthorizationServers:
assert user2.email == "user@keycloak.com"
@pytest.mark.asyncio
async def test_resource_server_rejects_unconfigured_as(self, rsa_keypair, jwks_data):
async def test_resource_server_rejects_unconfigured_as(
self, rsa_keypair, jwks_data, rsa_joserfc_key
):
"""Test that tokens from unlisted AS are rejected."""
private_key, _ = rsa_keypair
payload = {
claims = {
"sub": "user123",
"email": "user@evil.com",
"iss": "https://evil.com", # Not in configured list (unauthorized issuer)
@ -1171,12 +1384,8 @@ class TestMultipleAuthorizationServers:
"exp": int(time.time()) + 3600,
"iat": int(time.time()),
}
token = jwt.encode(
payload,
private_key,
algorithm="RS256",
headers={"kid": "test-key-1"},
)
header = {"alg": "RS256", "kid": "test-key-1"}
token = jwt.encode(header, claims, rsa_joserfc_key)
with patch("httpx.AsyncClient.get") as mock_get:
mock_response = Mock()