22
33from __future__ import annotations
44
5+ import asyncio
6+ import time
57from base64 import urlsafe_b64decode
68from typing import TYPE_CHECKING , Any , Optional
79
810import boto3
911import jwt
12+ import requests
1013from botocore .config import Config
1114from botocore .exceptions import ClientError
1215from cryptography .hazmat .primitives .asymmetric .rsa import RSAPublicNumbers
3437class CognitoAuthStrategy (AuthPort ):
3538 """Authentication strategy using AWS Cognito User Pools."""
3639
40+ # Class-level JWKS cache: maps jwks_url → {"fetched_at": float, "keys": list[dict]}
41+ # Shared across all instances so pool restarts benefit from warm entries.
42+ _jwks_cache : dict [str , dict [str , Any ]] = {}
43+ _cache_ttl_seconds : int = 3600
44+
3745 def __init__ (
3846 self ,
3947 logger : LoggingPort ,
@@ -311,8 +319,15 @@ async def _get_public_key(self, kid: str) -> Any:
311319 """
312320 Get public key from Cognito JWKS endpoint.
313321
314- Fetches the JWKS, locates the entry matching ``kid``, and converts it
315- to a ``cryptography`` RSAPublicKey object suitable for use with PyJWT.
322+ Fetches the JWKS (using the class-level cache; re-fetches after TTL),
323+ locates the entry matching ``kid``, and converts it to a ``cryptography``
324+ RSAPublicKey object suitable for use with PyJWT.
325+
326+ The synchronous ``requests.get`` call is offloaded via
327+ ``asyncio.to_thread`` so the event loop is not blocked during the fetch.
328+
329+ On ``jwt.InvalidTokenError`` (non-RSA key) the cache entry is evicted so
330+ that a key rotation does not lock out valid tokens indefinitely.
316331
317332 Args:
318333 kid: Key ID from token header
@@ -324,18 +339,13 @@ async def _get_public_key(self, kid: str) -> Any:
324339 jwt.InvalidTokenError: If the matched key is not an RSA key
325340 """
326341 try :
327- # In production, you would cache JWKS and implement appropriate key rotation
328- # This is a simplified implementation
329- import requests
330-
331- # Add timeout to prevent hanging connections (security best practice)
332- response = requests .get (self .jwks_url , timeout = 30 )
333- response .raise_for_status ()
334- jwks = response .json ()
342+ jwks = await self ._fetch_jwks_cached ()
335343
336344 for key in jwks .get ("keys" , []):
337345 if key .get ("kid" ) == kid :
338346 if key .get ("kty" ) != "RSA" :
347+ # Evict the cache entry so a subsequent rotation is picked up.
348+ self ._jwks_cache .pop (self .jwks_url , None )
339349 raise jwt .InvalidTokenError (
340350 f"Unsupported key type: { key .get ('kty' )!r} . Only RSA keys are supported."
341351 )
@@ -351,6 +361,28 @@ async def _get_public_key(self, kid: str) -> Any:
351361 self ._logger .error ("Failed to get public key: %s" , e )
352362 return None
353363
364+ async def _fetch_jwks_cached (self ) -> dict [str , Any ]:
365+ """Return the JWKS document for this strategy's endpoint.
366+
367+ Serves from the class-level cache when the entry is younger than
368+ ``_cache_ttl_seconds``; otherwise fetches fresh via
369+ ``asyncio.to_thread`` and repopulates the cache.
370+ """
371+ cached = self ._jwks_cache .get (self .jwks_url )
372+ if cached is not None :
373+ age = time .monotonic () - cached ["fetched_at" ]
374+ if age < self ._cache_ttl_seconds :
375+ return cached ["jwks" ] # type: ignore[return-value]
376+
377+ def _do_fetch () -> dict [str , Any ]:
378+ response = requests .get (self .jwks_url , timeout = 30 )
379+ response .raise_for_status ()
380+ return response .json () # type: ignore[no-any-return]
381+
382+ jwks : dict [str , Any ] = await asyncio .to_thread (_do_fetch )
383+ self ._jwks_cache [self .jwks_url ] = {"fetched_at" : time .monotonic (), "jwks" : jwks }
384+ return jwks
385+
354386 def _map_groups_to_roles (self , groups : list [str ]) -> list [str ]:
355387 """
356388 Map Cognito groups to application roles.
0 commit comments