Skip to content

Commit 28c1863

Browse files
authored
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
1 parent a941eb7 commit 28c1863

3 files changed

Lines changed: 520 additions & 233 deletions

File tree

libs/arcade-mcp-server/arcade_mcp_server/resource_server/validators/jwks.py

Lines changed: 125 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,16 @@
44
Implements OAuth 2.1 Resource Server token validation using JWT with JWKS.
55
"""
66

7+
import binascii
78
import time
89
from typing import Any, cast
910

1011
import httpx
11-
from jose import jwk, jwt
12+
from joserfc import jws, jwt
13+
from joserfc.errors import JoseError
14+
from joserfc.jwk import KeySet, KeySetSerialization
15+
from joserfc.jws import JWSRegistry
16+
from joserfc.registry import HeaderParameter
1217

1318
from arcade_mcp_server.resource_server.base import (
1419
AccessTokenValidationOptions,
@@ -19,19 +24,36 @@
1924
TokenExpiredError,
2025
)
2126

22-
# Note: Only asymmetric algorithms supported
2327
SUPPORTED_ALGORITHMS = {
28+
# RSA
2429
"RS256",
2530
"RS384",
2631
"RS512",
32+
# ECDSA
2733
"ES256",
2834
"ES384",
2935
"ES512",
36+
# RSA-PSS
3037
"PS256",
3138
"PS384",
3239
"PS512",
40+
# EdDSA
41+
"Ed25519",
42+
"EdDSA",
3343
}
3444

45+
# EdDSA algorithm aliases
46+
EDDSA_ALGORITHMS = {"Ed25519", "EdDSA"}
47+
48+
# Custom JWS registry that allows additional header params beyond joserfc's default
49+
_ACCESS_TOKEN_REGISTRY = JWSRegistry(
50+
header_registry={
51+
"iss": HeaderParameter("Issuer", "str"),
52+
"aud": HeaderParameter("Audience", "str"),
53+
},
54+
algorithms=list(SUPPORTED_ALGORITHMS),
55+
)
56+
3557

3658
class JWKSTokenValidator(ResourceServerValidator):
3759
"""JWKS-based JWT token validator for simple, explicit token validation.
@@ -114,6 +136,31 @@ def __init__(
114136
self._jwks_cache: dict[str, Any] | None = None
115137
self._cache_timestamp: float = 0
116138

139+
def _normalize_algorithm(self, alg: str) -> str:
140+
"""Normalize algorithm name for comparison.
141+
142+
EdDSA has multiple names (EdDSA, Ed25519) that should be treated as equivalent.
143+
144+
Args:
145+
alg: Algorithm name
146+
147+
Returns:
148+
Normalized algorithm name
149+
"""
150+
return "Ed25519" if alg in EDDSA_ALGORITHMS else alg
151+
152+
def _algorithms_match(self, alg1: str, alg2: str) -> bool:
153+
"""Check if two algorithm names match (considering EdDSA aliases).
154+
155+
Args:
156+
alg1: First algorithm name
157+
alg2: Second algorithm name
158+
159+
Returns:
160+
True if algorithms match
161+
"""
162+
return self._normalize_algorithm(alg1) == self._normalize_algorithm(alg2)
163+
117164
async def _fetch_jwks(self) -> dict[str, Any]:
118165
"""Fetch JWKS with caching.
119166
@@ -139,6 +186,25 @@ async def _fetch_jwks(self) -> dict[str, Any]:
139186
else:
140187
return self._jwks_cache
141188

189+
def _get_headers_without_verification(self, token: str) -> dict[str, Any]:
190+
"""Extract header from JWT without verification.
191+
192+
Args:
193+
token: JWT token string
194+
195+
Returns:
196+
Header dictionary containing 'alg', 'kid', etc.
197+
198+
Raises:
199+
InvalidTokenError: If token format is invalid
200+
"""
201+
try:
202+
# Use joserfc's extract_compact to parse JWT without verification
203+
obj = jws.extract_compact(token.encode())
204+
return obj.headers()
205+
except (JoseError, ValueError, binascii.Error) as e:
206+
raise InvalidTokenError(f"Invalid JWT format: {e}") from e
207+
142208
def _find_signing_key(self, jwks: dict[str, Any], token: str) -> Any:
143209
"""Find the signing key from JWKS that matches the token's kid.
144210
@@ -147,74 +213,92 @@ def _find_signing_key(self, jwks: dict[str, Any], token: str) -> Any:
147213
token: JWT token
148214
149215
Returns:
150-
Signing key in PEM format
216+
Key object from joserfc KeySet
151217
152218
Raises:
153219
InvalidTokenError: If no matching key found or algorithm mismatch
154220
"""
155-
unverified_header = jwt.get_unverified_header(token)
156-
kid = unverified_header.get("kid")
157-
token_alg = unverified_header.get("alg")
221+
header = self._get_headers_without_verification(token)
222+
kid = header.get("kid")
223+
token_alg = header.get("alg")
158224

159225
# Validate token algorithm matches configuration (prevent algorithm confusion)
160-
if token_alg and token_alg != self.algorithm:
226+
if token_alg and not self._algorithms_match(token_alg, self.algorithm):
161227
raise InvalidTokenError(
162228
f"Token algorithm '{token_alg}' doesn't match "
163229
f"configured algorithm '{self.algorithm}'"
164230
)
165231

166-
for key_data in jwks.get("keys", []):
167-
if key_data.get("kid") == kid:
168-
key_alg = key_data.get("alg")
232+
try:
233+
key_set = KeySet.import_key_set(cast(KeySetSerialization, jwks))
234+
except Exception as e:
235+
raise InvalidTokenError(f"Failed to import JWKS: {e}") from e
169236

170-
if key_alg and key_alg != self.algorithm:
237+
# Find key by kid
238+
for key in key_set.keys:
239+
if key.kid == kid:
240+
key_alg = key.alg
241+
if key_alg and not self._algorithms_match(key_alg, self.algorithm):
171242
raise InvalidTokenError(
172243
f"Key algorithm '{key_alg}' doesn't match "
173244
f"configured algorithm '{self.algorithm}'"
174245
)
175-
176-
key_obj = jwk.construct(key_data, algorithm=self.algorithm)
177-
return key_obj.to_pem().decode("utf-8")
246+
return key
178247

179248
raise InvalidTokenError("No matching key found in JWKS")
180249

181-
def _decode_token(self, token: str, signing_key: str) -> dict[str, Any]:
250+
def _decode_token(self, token: str, signing_key: Any) -> dict[str, Any]:
182251
"""Decode and verify the provided JWT token.
183252
253+
Uses jwt.decode for signature verification and payload parsing, then
254+
performs time-based claims validation manually for leeway handling
255+
184256
Args:
185257
token: JWT token
186-
signing_key: Public key in PEM format
258+
signing_key: Key object from joserfc
187259
188260
Returns:
189261
Decoded token claims
190262
191263
Raises:
192-
jwt.ExpiredSignatureError: Token has expired
193-
jwt.JWTClaimsError: Token claims validation failed (audience/issuer mismatch)
194-
jwt.JWTError: Token is invalid
264+
TokenExpiredError: Token has expired
265+
InvalidTokenError: Token validation failed
195266
"""
196-
decode_options = {
197-
"verify_signature": True, # Always verify signature. Cannot be disabled.
198-
"verify_exp": self.validation_options.verify_exp,
199-
"verify_iat": self.validation_options.verify_iat,
200-
"verify_nbf": self.validation_options.verify_nbf,
201-
"verify_aud": False, # Manual validation for multi-audience support
202-
"verify_iss": False, # Manual validation for multi-issuer support
203-
"leeway": self.validation_options.leeway,
204-
}
205-
206-
# Decode token once without aud/iss validation
207-
decoded = cast(
208-
dict[str, Any],
209-
jwt.decode(
210-
token,
211-
signing_key,
212-
algorithms=[self.algorithm],
213-
options=decode_options,
214-
),
215-
)
216-
217-
# Manually validate issuer (if flag is enabled)
267+
if self.algorithm in EDDSA_ALGORITHMS:
268+
algorithms = list(EDDSA_ALGORITHMS)
269+
else:
270+
algorithms = [self.algorithm]
271+
272+
# First, verify signature & decode payload
273+
try:
274+
result = jwt.decode(
275+
token, signing_key, algorithms=algorithms, registry=_ACCESS_TOKEN_REGISTRY
276+
)
277+
except JoseError as e:
278+
raise InvalidTokenError(f"Token signature verification failed: {e}") from e
279+
280+
decoded = result.claims
281+
282+
# Validate time based claims with leeway support
283+
current_time = int(time.time())
284+
leeway = self.validation_options.leeway
285+
286+
if self.validation_options.verify_exp:
287+
exp = decoded.get("exp")
288+
if exp is not None and exp + leeway < current_time:
289+
raise TokenExpiredError("Token has expired")
290+
291+
if self.validation_options.verify_iat:
292+
iat = decoded.get("iat")
293+
if iat is not None and iat - leeway > current_time:
294+
raise InvalidTokenError("Token issued in the future")
295+
296+
if self.validation_options.verify_nbf:
297+
nbf = decoded.get("nbf")
298+
if nbf is not None and nbf - leeway > current_time:
299+
raise InvalidTokenError("Token not yet valid")
300+
301+
# Manually validate issuer (if enabled)
218302
if self.validation_options.verify_iss:
219303
token_iss = decoded.get("iss")
220304
if isinstance(self.issuer, list):
@@ -313,12 +397,6 @@ async def validate_token(self, token: str) -> ResourceOwner:
313397
claims=decoded,
314398
)
315399

316-
except jwt.ExpiredSignatureError as e:
317-
raise TokenExpiredError("Token has expired") from e
318-
except jwt.JWTClaimsError as e:
319-
raise InvalidTokenError(f"Token claims validation failed: {e}") from e
320-
except jwt.JWTError as e:
321-
raise InvalidTokenError(f"Invalid token: {e}") from e
322400
except (InvalidTokenError, TokenExpiredError):
323401
raise
324402
except Exception as e:

libs/arcade-mcp-server/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "arcade-mcp-server"
7-
version = "1.14.2"
7+
version = "1.15.0"
88
description = "Model Context Protocol (MCP) server framework for Arcade.dev"
99
readme = "README.md"
1010
authors = [{ name = "Arcade.dev" }]
@@ -34,7 +34,7 @@ dependencies = [
3434
"anyio>=4.0.0",
3535
"python-dotenv>=1.0.0",
3636
"pydantic-settings>=2.10.1",
37-
"python-jose[cryptography]>=3.3.0,<4.0.0",
37+
"joserfc>=1.5.0",
3838
"httpx>=0.27.0,<1.0.0",
3939
]
4040

0 commit comments

Comments
 (0)