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:
parent
a941eb7ffe
commit
28c1863ee3
3 changed files with 518 additions and 231 deletions
|
|
@ -4,11 +4,16 @@ JWKS-based token validator for MCP Resource Servers.
|
||||||
Implements OAuth 2.1 Resource Server token validation using JWT with JWKS.
|
Implements OAuth 2.1 Resource Server token validation using JWT with JWKS.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import binascii
|
||||||
import time
|
import time
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
import httpx
|
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 (
|
from arcade_mcp_server.resource_server.base import (
|
||||||
AccessTokenValidationOptions,
|
AccessTokenValidationOptions,
|
||||||
|
|
@ -19,19 +24,36 @@ from arcade_mcp_server.resource_server.base import (
|
||||||
TokenExpiredError,
|
TokenExpiredError,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Note: Only asymmetric algorithms supported
|
|
||||||
SUPPORTED_ALGORITHMS = {
|
SUPPORTED_ALGORITHMS = {
|
||||||
|
# RSA
|
||||||
"RS256",
|
"RS256",
|
||||||
"RS384",
|
"RS384",
|
||||||
"RS512",
|
"RS512",
|
||||||
|
# ECDSA
|
||||||
"ES256",
|
"ES256",
|
||||||
"ES384",
|
"ES384",
|
||||||
"ES512",
|
"ES512",
|
||||||
|
# RSA-PSS
|
||||||
"PS256",
|
"PS256",
|
||||||
"PS384",
|
"PS384",
|
||||||
"PS512",
|
"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):
|
class JWKSTokenValidator(ResourceServerValidator):
|
||||||
"""JWKS-based JWT token validator for simple, explicit token validation.
|
"""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._jwks_cache: dict[str, Any] | None = None
|
||||||
self._cache_timestamp: float = 0
|
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]:
|
async def _fetch_jwks(self) -> dict[str, Any]:
|
||||||
"""Fetch JWKS with caching.
|
"""Fetch JWKS with caching.
|
||||||
|
|
||||||
|
|
@ -139,6 +186,25 @@ class JWKSTokenValidator(ResourceServerValidator):
|
||||||
else:
|
else:
|
||||||
return self._jwks_cache
|
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:
|
def _find_signing_key(self, jwks: dict[str, Any], token: str) -> Any:
|
||||||
"""Find the signing key from JWKS that matches the token's kid.
|
"""Find the signing key from JWKS that matches the token's kid.
|
||||||
|
|
||||||
|
|
@ -147,74 +213,92 @@ class JWKSTokenValidator(ResourceServerValidator):
|
||||||
token: JWT token
|
token: JWT token
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Signing key in PEM format
|
Key object from joserfc KeySet
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
InvalidTokenError: If no matching key found or algorithm mismatch
|
InvalidTokenError: If no matching key found or algorithm mismatch
|
||||||
"""
|
"""
|
||||||
unverified_header = jwt.get_unverified_header(token)
|
header = self._get_headers_without_verification(token)
|
||||||
kid = unverified_header.get("kid")
|
kid = header.get("kid")
|
||||||
token_alg = unverified_header.get("alg")
|
token_alg = header.get("alg")
|
||||||
|
|
||||||
# Validate token algorithm matches configuration (prevent algorithm confusion)
|
# 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(
|
raise InvalidTokenError(
|
||||||
f"Token algorithm '{token_alg}' doesn't match "
|
f"Token algorithm '{token_alg}' doesn't match "
|
||||||
f"configured algorithm '{self.algorithm}'"
|
f"configured algorithm '{self.algorithm}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
for key_data in jwks.get("keys", []):
|
try:
|
||||||
if key_data.get("kid") == kid:
|
key_set = KeySet.import_key_set(cast(KeySetSerialization, jwks))
|
||||||
key_alg = key_data.get("alg")
|
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(
|
raise InvalidTokenError(
|
||||||
f"Key algorithm '{key_alg}' doesn't match "
|
f"Key algorithm '{key_alg}' doesn't match "
|
||||||
f"configured algorithm '{self.algorithm}'"
|
f"configured algorithm '{self.algorithm}'"
|
||||||
)
|
)
|
||||||
|
return key
|
||||||
key_obj = jwk.construct(key_data, algorithm=self.algorithm)
|
|
||||||
return key_obj.to_pem().decode("utf-8")
|
|
||||||
|
|
||||||
raise InvalidTokenError("No matching key found in JWKS")
|
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.
|
"""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:
|
Args:
|
||||||
token: JWT token
|
token: JWT token
|
||||||
signing_key: Public key in PEM format
|
signing_key: Key object from joserfc
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Decoded token claims
|
Decoded token claims
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
jwt.ExpiredSignatureError: Token has expired
|
TokenExpiredError: Token has expired
|
||||||
jwt.JWTClaimsError: Token claims validation failed (audience/issuer mismatch)
|
InvalidTokenError: Token validation failed
|
||||||
jwt.JWTError: Token is invalid
|
|
||||||
"""
|
"""
|
||||||
decode_options = {
|
if self.algorithm in EDDSA_ALGORITHMS:
|
||||||
"verify_signature": True, # Always verify signature. Cannot be disabled.
|
algorithms = list(EDDSA_ALGORITHMS)
|
||||||
"verify_exp": self.validation_options.verify_exp,
|
else:
|
||||||
"verify_iat": self.validation_options.verify_iat,
|
algorithms = [self.algorithm]
|
||||||
"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,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Decode token once without aud/iss validation
|
# First, verify signature & decode payload
|
||||||
decoded = cast(
|
try:
|
||||||
dict[str, Any],
|
result = jwt.decode(
|
||||||
jwt.decode(
|
token, signing_key, algorithms=algorithms, registry=_ACCESS_TOKEN_REGISTRY
|
||||||
token,
|
)
|
||||||
signing_key,
|
except JoseError as e:
|
||||||
algorithms=[self.algorithm],
|
raise InvalidTokenError(f"Token signature verification failed: {e}") from e
|
||||||
options=decode_options,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# 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:
|
if self.validation_options.verify_iss:
|
||||||
token_iss = decoded.get("iss")
|
token_iss = decoded.get("iss")
|
||||||
if isinstance(self.issuer, list):
|
if isinstance(self.issuer, list):
|
||||||
|
|
@ -313,12 +397,6 @@ class JWKSTokenValidator(ResourceServerValidator):
|
||||||
claims=decoded,
|
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):
|
except (InvalidTokenError, TokenExpiredError):
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "arcade-mcp-server"
|
name = "arcade-mcp-server"
|
||||||
version = "1.14.2"
|
version = "1.15.0"
|
||||||
description = "Model Context Protocol (MCP) server framework for Arcade.dev"
|
description = "Model Context Protocol (MCP) server framework for Arcade.dev"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [{ name = "Arcade.dev" }]
|
authors = [{ name = "Arcade.dev" }]
|
||||||
|
|
@ -34,7 +34,7 @@ dependencies = [
|
||||||
"anyio>=4.0.0",
|
"anyio>=4.0.0",
|
||||||
"python-dotenv>=1.0.0",
|
"python-dotenv>=1.0.0",
|
||||||
"pydantic-settings>=2.10.1",
|
"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",
|
"httpx>=0.27.0,<1.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,9 @@ from arcade_mcp_server.resource_server.middleware import ResourceServerMiddlewar
|
||||||
from arcade_mcp_server.worker import create_arcade_mcp
|
from arcade_mcp_server.worker import create_arcade_mcp
|
||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
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
|
# Test fixtures
|
||||||
|
|
@ -49,11 +51,23 @@ def rsa_keypair():
|
||||||
return private_key, public_key
|
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
|
@pytest.fixture
|
||||||
def serialized_private_key(rsa_keypair):
|
def serialized_private_key(rsa_keypair):
|
||||||
"""Generate private key as PEM format for testing."""
|
"""Generate private key as PEM format for testing."""
|
||||||
private_key, _ = rsa_keypair
|
private_key, _ = rsa_keypair
|
||||||
# Serialize private key to PEM format for python-jose
|
# Serialize private key to PEM format
|
||||||
pem = private_key.private_bytes(
|
pem = private_key.private_bytes(
|
||||||
encoding=serialization.Encoding.PEM,
|
encoding=serialization.Encoding.PEM,
|
||||||
format=serialization.PrivateFormat.PKCS8,
|
format=serialization.PrivateFormat.PKCS8,
|
||||||
|
|
@ -94,11 +108,9 @@ def jwks_data(rsa_keypair):
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def valid_jwt_token(rsa_keypair):
|
def valid_jwt_token(rsa_joserfc_key):
|
||||||
"""Generate valid JWT token for testing."""
|
"""Generate valid JWT token for testing."""
|
||||||
private_key, _ = rsa_keypair
|
claims = {
|
||||||
|
|
||||||
payload = {
|
|
||||||
"sub": "user123",
|
"sub": "user123",
|
||||||
"email": "user@example.com",
|
"email": "user@example.com",
|
||||||
"iss": "https://auth.example.com",
|
"iss": "https://auth.example.com",
|
||||||
|
|
@ -107,22 +119,16 @@ def valid_jwt_token(rsa_keypair):
|
||||||
"iat": int(time.time()),
|
"iat": int(time.time()),
|
||||||
}
|
}
|
||||||
|
|
||||||
token = jwt.encode(
|
header = {"alg": "RS256", "kid": "test-key-1"}
|
||||||
payload,
|
token = jwt.encode(header, claims, rsa_joserfc_key)
|
||||||
private_key,
|
|
||||||
algorithm="RS256",
|
|
||||||
headers={"kid": "test-key-1"},
|
|
||||||
)
|
|
||||||
|
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def expired_jwt_token(rsa_keypair):
|
def expired_jwt_token(rsa_joserfc_key):
|
||||||
"""Generate expired JWT token for testing."""
|
"""Generate expired JWT token for testing."""
|
||||||
private_key, _ = rsa_keypair
|
claims = {
|
||||||
|
|
||||||
payload = {
|
|
||||||
"sub": "user123",
|
"sub": "user123",
|
||||||
"email": "user@example.com",
|
"email": "user@example.com",
|
||||||
"iss": "https://auth.example.com",
|
"iss": "https://auth.example.com",
|
||||||
|
|
@ -131,12 +137,92 @@ def expired_jwt_token(rsa_keypair):
|
||||||
"iat": int(time.time()) - 7200,
|
"iat": int(time.time()) - 7200,
|
||||||
}
|
}
|
||||||
|
|
||||||
token = jwt.encode(
|
header = {"alg": "RS256", "kid": "test-key-1"}
|
||||||
payload,
|
token = jwt.encode(header, claims, rsa_joserfc_key)
|
||||||
private_key,
|
|
||||||
algorithm="RS256",
|
return token
|
||||||
headers={"kid": "test-key-1"},
|
|
||||||
|
|
||||||
|
# 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
|
return token
|
||||||
|
|
||||||
|
|
@ -185,9 +271,9 @@ class TestJWKSTokenValidator:
|
||||||
await validator.validate_token(expired_jwt_token)
|
await validator.validate_token(expired_jwt_token)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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."""
|
"""Test validating token with wrong audience."""
|
||||||
payload = {
|
claims = {
|
||||||
"sub": "user123",
|
"sub": "user123",
|
||||||
"iss": "https://auth.example.com",
|
"iss": "https://auth.example.com",
|
||||||
"aud": "https://wrong-server.com", # Wrong audience
|
"aud": "https://wrong-server.com", # Wrong audience
|
||||||
|
|
@ -195,12 +281,8 @@ class TestJWKSTokenValidator:
|
||||||
"iat": int(time.time()),
|
"iat": int(time.time()),
|
||||||
}
|
}
|
||||||
|
|
||||||
token = jwt.encode(
|
header = {"alg": "RS256", "kid": "test-key-1"}
|
||||||
payload,
|
token = jwt.encode(header, claims, rsa_joserfc_key)
|
||||||
serialized_private_key,
|
|
||||||
algorithm="RS256",
|
|
||||||
headers={"kid": "test-key-1"},
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("httpx.AsyncClient.get") as mock_get:
|
with patch("httpx.AsyncClient.get") as mock_get:
|
||||||
mock_response = Mock()
|
mock_response = Mock()
|
||||||
|
|
@ -218,9 +300,9 @@ class TestJWKSTokenValidator:
|
||||||
await validator.validate_token(token)
|
await validator.validate_token(token)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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."""
|
"""Test validating token with wrong issuer."""
|
||||||
payload = {
|
claims = {
|
||||||
"sub": "user123",
|
"sub": "user123",
|
||||||
"iss": "https://wrong-issuer.com", # Wrong issuer
|
"iss": "https://wrong-issuer.com", # Wrong issuer
|
||||||
"aud": "https://mcp.example.com/mcp",
|
"aud": "https://mcp.example.com/mcp",
|
||||||
|
|
@ -228,12 +310,8 @@ class TestJWKSTokenValidator:
|
||||||
"iat": int(time.time()),
|
"iat": int(time.time()),
|
||||||
}
|
}
|
||||||
|
|
||||||
token = jwt.encode(
|
header = {"alg": "RS256", "kid": "test-key-1"}
|
||||||
payload,
|
token = jwt.encode(header, claims, rsa_joserfc_key)
|
||||||
serialized_private_key,
|
|
||||||
algorithm="RS256",
|
|
||||||
headers={"kid": "test-key-1"},
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("httpx.AsyncClient.get") as mock_get:
|
with patch("httpx.AsyncClient.get") as mock_get:
|
||||||
mock_response = Mock()
|
mock_response = Mock()
|
||||||
|
|
@ -251,21 +329,17 @@ class TestJWKSTokenValidator:
|
||||||
await validator.validate_token(token)
|
await validator.validate_token(token)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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."""
|
"""Test validating token without sub claim."""
|
||||||
payload = {
|
claims = {
|
||||||
"iss": "https://auth.example.com",
|
"iss": "https://auth.example.com",
|
||||||
"aud": "https://mcp.example.com/mcp",
|
"aud": "https://mcp.example.com/mcp",
|
||||||
"exp": int(time.time()) + 3600,
|
"exp": int(time.time()) + 3600,
|
||||||
"iat": int(time.time()),
|
"iat": int(time.time()),
|
||||||
}
|
}
|
||||||
|
|
||||||
token = jwt.encode(
|
header = {"alg": "RS256", "kid": "test-key-1"}
|
||||||
payload,
|
token = jwt.encode(header, claims, rsa_joserfc_key)
|
||||||
serialized_private_key,
|
|
||||||
algorithm="RS256",
|
|
||||||
headers={"kid": "test-key-1"},
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("httpx.AsyncClient.get") as mock_get:
|
with patch("httpx.AsyncClient.get") as mock_get:
|
||||||
mock_response = Mock()
|
mock_response = Mock()
|
||||||
|
|
@ -307,11 +381,9 @@ class TestJWKSTokenValidator:
|
||||||
assert mock_get.call_count == 1
|
assert mock_get.call_count == 1
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_validate_multiple_audiences_single_token_aud(
|
async def test_validate_multiple_audiences_single_token_aud(self, rsa_joserfc_key, jwks_data):
|
||||||
self, serialized_private_key, jwks_data
|
|
||||||
):
|
|
||||||
"""Test validator with multiple audiences accepts token with matching single aud."""
|
"""Test validator with multiple audiences accepts token with matching single aud."""
|
||||||
payload = {
|
claims = {
|
||||||
"sub": "user123",
|
"sub": "user123",
|
||||||
"iss": "https://auth.example.com",
|
"iss": "https://auth.example.com",
|
||||||
"aud": "https://old-mcp.example.com", # Matches first audience
|
"aud": "https://old-mcp.example.com", # Matches first audience
|
||||||
|
|
@ -319,12 +391,8 @@ class TestJWKSTokenValidator:
|
||||||
"iat": int(time.time()),
|
"iat": int(time.time()),
|
||||||
}
|
}
|
||||||
|
|
||||||
token = jwt.encode(
|
header = {"alg": "RS256", "kid": "test-key-1"}
|
||||||
payload,
|
token = jwt.encode(header, claims, rsa_joserfc_key)
|
||||||
serialized_private_key,
|
|
||||||
algorithm="RS256",
|
|
||||||
headers={"kid": "test-key-1"},
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("httpx.AsyncClient.get") as mock_get:
|
with patch("httpx.AsyncClient.get") as mock_get:
|
||||||
mock_response = Mock()
|
mock_response = Mock()
|
||||||
|
|
@ -342,12 +410,10 @@ class TestJWKSTokenValidator:
|
||||||
assert user.user_id == "user123"
|
assert user.user_id == "user123"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_validate_multiple_audiences_list_token_aud(
|
async def test_validate_multiple_audiences_list_token_aud(self, rsa_joserfc_key, jwks_data):
|
||||||
self, serialized_private_key, jwks_data
|
|
||||||
):
|
|
||||||
"""Test validator with multiple audiences accepts token with list aud."""
|
"""Test validator with multiple audiences accepts token with list aud."""
|
||||||
# Token with list of audiences where one matches the validator's accepted audiences
|
# Token with list of audiences where one matches the validator's accepted audiences
|
||||||
payload = {
|
claims = {
|
||||||
"sub": "user123",
|
"sub": "user123",
|
||||||
"iss": "https://auth.example.com",
|
"iss": "https://auth.example.com",
|
||||||
"aud": ["https://api1.com", "https://new-mcp.example.com"], # Second matches
|
"aud": ["https://api1.com", "https://new-mcp.example.com"], # Second matches
|
||||||
|
|
@ -355,12 +421,8 @@ class TestJWKSTokenValidator:
|
||||||
"iat": int(time.time()),
|
"iat": int(time.time()),
|
||||||
}
|
}
|
||||||
|
|
||||||
token = jwt.encode(
|
header = {"alg": "RS256", "kid": "test-key-1"}
|
||||||
payload,
|
token = jwt.encode(header, claims, rsa_joserfc_key)
|
||||||
serialized_private_key,
|
|
||||||
algorithm="RS256",
|
|
||||||
headers={"kid": "test-key-1"},
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("httpx.AsyncClient.get") as mock_get:
|
with patch("httpx.AsyncClient.get") as mock_get:
|
||||||
mock_response = Mock()
|
mock_response = Mock()
|
||||||
|
|
@ -378,10 +440,10 @@ class TestJWKSTokenValidator:
|
||||||
assert user.user_id == "user123"
|
assert user.user_id == "user123"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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."""
|
"""Test validator with multiple audiences rejects token with non-matching aud."""
|
||||||
# Token with audience that doesn't match any of validator's accepted audiences
|
# Token with audience that doesn't match any of validator's accepted audiences
|
||||||
payload = {
|
claims = {
|
||||||
"sub": "user123",
|
"sub": "user123",
|
||||||
"iss": "https://auth.example.com",
|
"iss": "https://auth.example.com",
|
||||||
"aud": "https://different-server.com", # Doesn't match
|
"aud": "https://different-server.com", # Doesn't match
|
||||||
|
|
@ -389,12 +451,8 @@ class TestJWKSTokenValidator:
|
||||||
"iat": int(time.time()),
|
"iat": int(time.time()),
|
||||||
}
|
}
|
||||||
|
|
||||||
token = jwt.encode(
|
header = {"alg": "RS256", "kid": "test-key-1"}
|
||||||
payload,
|
token = jwt.encode(header, claims, rsa_joserfc_key)
|
||||||
serialized_private_key,
|
|
||||||
algorithm="RS256",
|
|
||||||
headers={"kid": "test-key-1"},
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("httpx.AsyncClient.get") as mock_get:
|
with patch("httpx.AsyncClient.get") as mock_get:
|
||||||
mock_response = Mock()
|
mock_response = Mock()
|
||||||
|
|
@ -412,12 +470,10 @@ class TestJWKSTokenValidator:
|
||||||
await validator.validate_token(token)
|
await validator.validate_token(token)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_validate_single_audience_with_list_token_aud(
|
async def test_validate_single_audience_with_list_token_aud(self, rsa_joserfc_key, jwks_data):
|
||||||
self, serialized_private_key, jwks_data
|
|
||||||
):
|
|
||||||
"""Test validator with single audience accepts token with list aud containing match."""
|
"""Test validator with single audience accepts token with list aud containing match."""
|
||||||
# Token with list of audiences where one matches validator's single audience
|
# Token with list of audiences where one matches validator's single audience
|
||||||
payload = {
|
claims = {
|
||||||
"sub": "user123",
|
"sub": "user123",
|
||||||
"iss": "https://auth.example.com",
|
"iss": "https://auth.example.com",
|
||||||
"aud": ["https://api1.com", "https://mcp.example.com/mcp", "https://api2.com"],
|
"aud": ["https://api1.com", "https://mcp.example.com/mcp", "https://api2.com"],
|
||||||
|
|
@ -425,12 +481,8 @@ class TestJWKSTokenValidator:
|
||||||
"iat": int(time.time()),
|
"iat": int(time.time()),
|
||||||
}
|
}
|
||||||
|
|
||||||
token = jwt.encode(
|
header = {"alg": "RS256", "kid": "test-key-1"}
|
||||||
payload,
|
token = jwt.encode(header, claims, rsa_joserfc_key)
|
||||||
serialized_private_key,
|
|
||||||
algorithm="RS256",
|
|
||||||
headers={"kid": "test-key-1"},
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("httpx.AsyncClient.get") as mock_get:
|
with patch("httpx.AsyncClient.get") as mock_get:
|
||||||
mock_response = Mock()
|
mock_response = Mock()
|
||||||
|
|
@ -448,10 +500,10 @@ class TestJWKSTokenValidator:
|
||||||
assert user.user_id == "user123"
|
assert user.user_id == "user123"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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)."""
|
"""Test that multi-issuer validation is efficient (single decode)."""
|
||||||
# Token from second issuer in list
|
# Token from second issuer in list
|
||||||
payload = {
|
claims = {
|
||||||
"sub": "user123",
|
"sub": "user123",
|
||||||
"iss": "https://auth2.example.com", # Second in list
|
"iss": "https://auth2.example.com", # Second in list
|
||||||
"aud": "https://mcp.example.com/mcp",
|
"aud": "https://mcp.example.com/mcp",
|
||||||
|
|
@ -459,12 +511,8 @@ class TestJWKSTokenValidator:
|
||||||
"iat": int(time.time()),
|
"iat": int(time.time()),
|
||||||
}
|
}
|
||||||
|
|
||||||
token = jwt.encode(
|
header = {"alg": "RS256", "kid": "test-key-1"}
|
||||||
payload,
|
token = jwt.encode(header, claims, rsa_joserfc_key)
|
||||||
serialized_private_key,
|
|
||||||
algorithm="RS256",
|
|
||||||
headers={"kid": "test-key-1"},
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("httpx.AsyncClient.get") as mock_get:
|
with patch("httpx.AsyncClient.get") as mock_get:
|
||||||
mock_response = Mock()
|
mock_response = Mock()
|
||||||
|
|
@ -472,9 +520,9 @@ class TestJWKSTokenValidator:
|
||||||
mock_response.raise_for_status = Mock()
|
mock_response.raise_for_status = Mock()
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
# Patch jwt.decode to count calls
|
|
||||||
with patch(
|
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:
|
) as mock_decode:
|
||||||
validator = JWKSTokenValidator(
|
validator = JWKSTokenValidator(
|
||||||
jwks_uri="https://auth.example.com/.well-known/jwks.json",
|
jwks_uri="https://auth.example.com/.well-known/jwks.json",
|
||||||
|
|
@ -493,9 +541,9 @@ class TestJWKSTokenValidator:
|
||||||
assert mock_decode.call_count == 1
|
assert mock_decode.call_count == 1
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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."""
|
"""Test that token with nbf claim in the future is rejected."""
|
||||||
payload = {
|
claims = {
|
||||||
"sub": "user123",
|
"sub": "user123",
|
||||||
"iss": "https://auth.example.com",
|
"iss": "https://auth.example.com",
|
||||||
"aud": "https://mcp.example.com/mcp",
|
"aud": "https://mcp.example.com/mcp",
|
||||||
|
|
@ -504,12 +552,8 @@ class TestJWKSTokenValidator:
|
||||||
"nbf": int(time.time()) + 3600, # Not valid for 1 hour
|
"nbf": int(time.time()) + 3600, # Not valid for 1 hour
|
||||||
}
|
}
|
||||||
|
|
||||||
token = jwt.encode(
|
header = {"alg": "RS256", "kid": "test-key-1"}
|
||||||
payload,
|
token = jwt.encode(header, claims, rsa_joserfc_key)
|
||||||
serialized_private_key,
|
|
||||||
algorithm="RS256",
|
|
||||||
headers={"kid": "test-key-1"},
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("httpx.AsyncClient.get") as mock_get:
|
with patch("httpx.AsyncClient.get") as mock_get:
|
||||||
mock_response = Mock()
|
mock_response = Mock()
|
||||||
|
|
@ -528,9 +572,9 @@ class TestJWKSTokenValidator:
|
||||||
await validator.validate_token(token)
|
await validator.validate_token(token)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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."""
|
"""Test that token with nbf in future is accepted when verify_nbf=False."""
|
||||||
payload = {
|
claims = {
|
||||||
"sub": "user123",
|
"sub": "user123",
|
||||||
"iss": "https://auth.example.com",
|
"iss": "https://auth.example.com",
|
||||||
"aud": "https://mcp.example.com/mcp",
|
"aud": "https://mcp.example.com/mcp",
|
||||||
|
|
@ -539,12 +583,8 @@ class TestJWKSTokenValidator:
|
||||||
"nbf": int(time.time()) + 3600, # Not valid for 1 hour
|
"nbf": int(time.time()) + 3600, # Not valid for 1 hour
|
||||||
}
|
}
|
||||||
|
|
||||||
token = jwt.encode(
|
header = {"alg": "RS256", "kid": "test-key-1"}
|
||||||
payload,
|
token = jwt.encode(header, claims, rsa_joserfc_key)
|
||||||
serialized_private_key,
|
|
||||||
algorithm="RS256",
|
|
||||||
headers={"kid": "test-key-1"},
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("httpx.AsyncClient.get") as mock_get:
|
with patch("httpx.AsyncClient.get") as mock_get:
|
||||||
mock_response = Mock()
|
mock_response = Mock()
|
||||||
|
|
@ -564,10 +604,10 @@ class TestJWKSTokenValidator:
|
||||||
assert user.user_id == "user123"
|
assert user.user_id == "user123"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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."""
|
"""Test that leeway allows slightly expired tokens."""
|
||||||
# Token expired 30 seconds ago
|
# Token expired 30 seconds ago
|
||||||
payload = {
|
claims = {
|
||||||
"sub": "user123",
|
"sub": "user123",
|
||||||
"iss": "https://auth.example.com",
|
"iss": "https://auth.example.com",
|
||||||
"aud": "https://mcp.example.com/mcp",
|
"aud": "https://mcp.example.com/mcp",
|
||||||
|
|
@ -575,12 +615,8 @@ class TestJWKSTokenValidator:
|
||||||
"iat": int(time.time()) - 3600,
|
"iat": int(time.time()) - 3600,
|
||||||
}
|
}
|
||||||
|
|
||||||
token = jwt.encode(
|
header = {"alg": "RS256", "kid": "test-key-1"}
|
||||||
payload,
|
token = jwt.encode(header, claims, rsa_joserfc_key)
|
||||||
serialized_private_key,
|
|
||||||
algorithm="RS256",
|
|
||||||
headers={"kid": "test-key-1"},
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("httpx.AsyncClient.get") as mock_get:
|
with patch("httpx.AsyncClient.get") as mock_get:
|
||||||
mock_response = Mock()
|
mock_response = Mock()
|
||||||
|
|
@ -600,6 +636,209 @@ class TestJWKSTokenValidator:
|
||||||
assert user.user_id == "user123"
|
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
|
# ResourceServerAuth Tests
|
||||||
class TestResourceServerAuth:
|
class TestResourceServerAuth:
|
||||||
"""Tests for ResourceServerAuth class."""
|
"""Tests for ResourceServerAuth class."""
|
||||||
|
|
@ -639,12 +878,10 @@ class TestResourceServerAuth:
|
||||||
assert metadata["bearer_methods_supported"] == ["header"]
|
assert metadata["bearer_methods_supported"] == ["header"]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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."""
|
"""Test that expected_audiences overrides canonical_url for audience validation."""
|
||||||
private_key, _ = rsa_keypair
|
|
||||||
|
|
||||||
# Token with custom audience
|
# Token with custom audience
|
||||||
payload = {
|
claims = {
|
||||||
"sub": "user123",
|
"sub": "user123",
|
||||||
"iss": "https://auth.example.com",
|
"iss": "https://auth.example.com",
|
||||||
"aud": "my-authkit-client-id",
|
"aud": "my-authkit-client-id",
|
||||||
|
|
@ -652,12 +889,8 @@ class TestResourceServerAuth:
|
||||||
"iat": int(time.time()),
|
"iat": int(time.time()),
|
||||||
}
|
}
|
||||||
|
|
||||||
token = jwt.encode(
|
header = {"alg": "RS256", "kid": "test-key-1"}
|
||||||
payload,
|
token = jwt.encode(header, claims, rsa_joserfc_key)
|
||||||
private_key,
|
|
||||||
algorithm="RS256",
|
|
||||||
headers={"kid": "test-key-1"},
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("httpx.AsyncClient.get") as mock_get:
|
with patch("httpx.AsyncClient.get") as mock_get:
|
||||||
mock_response = Mock()
|
mock_response = Mock()
|
||||||
|
|
@ -681,12 +914,12 @@ class TestResourceServerAuth:
|
||||||
assert user.user_id == "user123"
|
assert user.user_id == "user123"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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."""
|
"""Test that multiple expected_audiences work correctly."""
|
||||||
private_key, _ = rsa_keypair
|
|
||||||
|
|
||||||
# Token with one of the expected audiences
|
# Token with one of the expected audiences
|
||||||
payload = {
|
claims = {
|
||||||
"sub": "user123",
|
"sub": "user123",
|
||||||
"iss": "https://auth.example.com",
|
"iss": "https://auth.example.com",
|
||||||
"aud": "secondary-client-id",
|
"aud": "secondary-client-id",
|
||||||
|
|
@ -694,12 +927,8 @@ class TestResourceServerAuth:
|
||||||
"iat": int(time.time()),
|
"iat": int(time.time()),
|
||||||
}
|
}
|
||||||
|
|
||||||
token = jwt.encode(
|
header = {"alg": "RS256", "kid": "test-key-1"}
|
||||||
payload,
|
token = jwt.encode(header, claims, rsa_joserfc_key)
|
||||||
private_key,
|
|
||||||
algorithm="RS256",
|
|
||||||
headers={"kid": "test-key-1"},
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("httpx.AsyncClient.get") as mock_get:
|
with patch("httpx.AsyncClient.get") as mock_get:
|
||||||
mock_response = Mock()
|
mock_response = Mock()
|
||||||
|
|
@ -727,11 +956,11 @@ class TestResourceServerAuth:
|
||||||
assert user.user_id == "user123"
|
assert user.user_id == "user123"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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."""
|
"""Test that without expected_audiences, canonical_url is used for audience validation."""
|
||||||
private_key, _ = rsa_keypair
|
claims = {
|
||||||
|
|
||||||
payload = {
|
|
||||||
"sub": "user123",
|
"sub": "user123",
|
||||||
"iss": "https://auth.example.com",
|
"iss": "https://auth.example.com",
|
||||||
"aud": "https://mcp.example.com/mcp",
|
"aud": "https://mcp.example.com/mcp",
|
||||||
|
|
@ -739,12 +968,8 @@ class TestResourceServerAuth:
|
||||||
"iat": int(time.time()),
|
"iat": int(time.time()),
|
||||||
}
|
}
|
||||||
|
|
||||||
token = jwt.encode(
|
header = {"alg": "RS256", "kid": "test-key-1"}
|
||||||
payload,
|
token = jwt.encode(header, claims, rsa_joserfc_key)
|
||||||
private_key,
|
|
||||||
algorithm="RS256",
|
|
||||||
headers={"kid": "test-key-1"},
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("httpx.AsyncClient.get") as mock_get:
|
with patch("httpx.AsyncClient.get") as mock_get:
|
||||||
mock_response = Mock()
|
mock_response = Mock()
|
||||||
|
|
@ -767,11 +992,11 @@ class TestResourceServerAuth:
|
||||||
assert user.user_id == "user123"
|
assert user.user_id == "user123"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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."""
|
"""Test that tokens with wrong audience are rejected even with expected_audiences."""
|
||||||
private_key, _ = rsa_keypair
|
claims = {
|
||||||
|
|
||||||
payload = {
|
|
||||||
"sub": "user123",
|
"sub": "user123",
|
||||||
"iss": "https://auth.example.com",
|
"iss": "https://auth.example.com",
|
||||||
"aud": "wrong-client-id", # Not in expected_audiences list
|
"aud": "wrong-client-id", # Not in expected_audiences list
|
||||||
|
|
@ -779,12 +1004,8 @@ class TestResourceServerAuth:
|
||||||
"iat": int(time.time()),
|
"iat": int(time.time()),
|
||||||
}
|
}
|
||||||
|
|
||||||
token = jwt.encode(
|
header = {"alg": "RS256", "kid": "test-key-1"}
|
||||||
payload,
|
token = jwt.encode(header, claims, rsa_joserfc_key)
|
||||||
private_key,
|
|
||||||
algorithm="RS256",
|
|
||||||
headers={"kid": "test-key-1"},
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("httpx.AsyncClient.get") as mock_get:
|
with patch("httpx.AsyncClient.get") as mock_get:
|
||||||
mock_response = Mock()
|
mock_response = Mock()
|
||||||
|
|
@ -1085,11 +1306,11 @@ class TestMultipleAuthorizationServers:
|
||||||
assert user.email == "user@example.com"
|
assert user.email == "user@example.com"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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)."""
|
"""Test multiple AS with different JWKS (multi-IdP)."""
|
||||||
private_key, _ = rsa_keypair
|
claims1 = {
|
||||||
|
|
||||||
payload1 = {
|
|
||||||
"sub": "user123",
|
"sub": "user123",
|
||||||
"email": "user@workos.com",
|
"email": "user@workos.com",
|
||||||
"iss": "https://workos.authkit.app",
|
"iss": "https://workos.authkit.app",
|
||||||
|
|
@ -1097,14 +1318,10 @@ class TestMultipleAuthorizationServers:
|
||||||
"exp": int(time.time()) + 3600,
|
"exp": int(time.time()) + 3600,
|
||||||
"iat": int(time.time()),
|
"iat": int(time.time()),
|
||||||
}
|
}
|
||||||
token1 = jwt.encode(
|
header1 = {"alg": "RS256", "kid": "test-key-1"}
|
||||||
payload1,
|
token1 = jwt.encode(header1, claims1, rsa_joserfc_key)
|
||||||
private_key,
|
|
||||||
algorithm="RS256",
|
|
||||||
headers={"kid": "test-key-1"},
|
|
||||||
)
|
|
||||||
|
|
||||||
payload2 = {
|
claims2 = {
|
||||||
"sub": "user456",
|
"sub": "user456",
|
||||||
"email": "user@keycloak.com",
|
"email": "user@keycloak.com",
|
||||||
"iss": "http://localhost:8080/realms/mcp-test",
|
"iss": "http://localhost:8080/realms/mcp-test",
|
||||||
|
|
@ -1112,12 +1329,8 @@ class TestMultipleAuthorizationServers:
|
||||||
"exp": int(time.time()) + 3600,
|
"exp": int(time.time()) + 3600,
|
||||||
"iat": int(time.time()),
|
"iat": int(time.time()),
|
||||||
}
|
}
|
||||||
token2 = jwt.encode(
|
header2 = {"alg": "RS256", "kid": "test-key-1"}
|
||||||
payload2,
|
token2 = jwt.encode(header2, claims2, rsa_joserfc_key)
|
||||||
private_key,
|
|
||||||
algorithm="RS256",
|
|
||||||
headers={"kid": "test-key-1"},
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("httpx.AsyncClient.get") as mock_get:
|
with patch("httpx.AsyncClient.get") as mock_get:
|
||||||
mock_response = Mock()
|
mock_response = Mock()
|
||||||
|
|
@ -1159,11 +1372,11 @@ class TestMultipleAuthorizationServers:
|
||||||
assert user2.email == "user@keycloak.com"
|
assert user2.email == "user@keycloak.com"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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."""
|
"""Test that tokens from unlisted AS are rejected."""
|
||||||
private_key, _ = rsa_keypair
|
claims = {
|
||||||
|
|
||||||
payload = {
|
|
||||||
"sub": "user123",
|
"sub": "user123",
|
||||||
"email": "user@evil.com",
|
"email": "user@evil.com",
|
||||||
"iss": "https://evil.com", # Not in configured list (unauthorized issuer)
|
"iss": "https://evil.com", # Not in configured list (unauthorized issuer)
|
||||||
|
|
@ -1171,12 +1384,8 @@ class TestMultipleAuthorizationServers:
|
||||||
"exp": int(time.time()) + 3600,
|
"exp": int(time.time()) + 3600,
|
||||||
"iat": int(time.time()),
|
"iat": int(time.time()),
|
||||||
}
|
}
|
||||||
token = jwt.encode(
|
header = {"alg": "RS256", "kid": "test-key-1"}
|
||||||
payload,
|
token = jwt.encode(header, claims, rsa_joserfc_key)
|
||||||
private_key,
|
|
||||||
algorithm="RS256",
|
|
||||||
headers={"kid": "test-key-1"},
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("httpx.AsyncClient.get") as mock_get:
|
with patch("httpx.AsyncClient.get") as mock_get:
|
||||||
mock_response = Mock()
|
mock_response = Mock()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue