44Implements OAuth 2.1 Resource Server token validation using JWT with JWKS.
55"""
66
7+ import binascii
78import time
89from typing import Any , cast
910
1011import 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
1318from arcade_mcp_server .resource_server .base import (
1419 AccessTokenValidationOptions ,
1924 TokenExpiredError ,
2025)
2126
22- # Note: Only asymmetric algorithms supported
2327SUPPORTED_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
3658class 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 :
0 commit comments