Skip to content

Commit f678d13

Browse files
committed
fix(auth/cognito): cache JWKS and offload fetch via asyncio.to_thread
1 parent dbf0975 commit f678d13

2 files changed

Lines changed: 109 additions & 10 deletions

File tree

src/orb/providers/aws/auth/cognito_strategy.py

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22

33
from __future__ import annotations
44

5+
import asyncio
6+
import time
57
from base64 import urlsafe_b64decode
68
from typing import TYPE_CHECKING, Any, Optional
79

810
import boto3
911
import jwt
12+
import requests
1013
from botocore.config import Config
1114
from botocore.exceptions import ClientError
1215
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
@@ -34,6 +37,11 @@
3437
class 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.

tests/providers/aws/unit/test_cognito_strategy.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ def _make_strategy(enabled: bool = True) -> Any:
3737

3838
logger = _make_logger()
3939

40+
# Clear the class-level JWKS cache so each test starts from a clean state.
41+
CognitoAuthStrategy._jwks_cache.clear()
42+
4043
with patch("boto3.client") as _mock_boto3:
4144
_mock_boto3.return_value = MagicMock()
4245
strategy = CognitoAuthStrategy(
@@ -384,3 +387,67 @@ async def test_get_public_key_non_2xx_propagates_as_invalid_token():
384387

385388
assert result.status == AuthStatus.INVALID
386389
assert "Unable to verify token signature" in (result.error_message or "")
390+
391+
392+
# ---------------------------------------------------------------------------
393+
# JWKS caching behaviour
394+
# ---------------------------------------------------------------------------
395+
396+
397+
@pytest.mark.asyncio
398+
@pytest.mark.unit
399+
async def test_jwks_fetch_is_cached():
400+
"""_get_public_key called twice with the same kid only fetches JWKS once."""
401+
strategy = _make_strategy()
402+
403+
mock_response = MagicMock()
404+
mock_response.json.return_value = {"keys": []} # kid not found → returns None both times
405+
406+
with patch("requests.get", return_value=mock_response) as mock_get:
407+
await strategy._get_public_key("some-kid")
408+
await strategy._get_public_key("some-kid")
409+
410+
# The HTTP fetch should happen only once; second call should hit the cache.
411+
mock_get.assert_called_once()
412+
413+
414+
@pytest.mark.asyncio
415+
@pytest.mark.unit
416+
async def test_jwks_cache_evicted_on_invalid_key_type():
417+
"""Cache entry is evicted when _get_public_key raises InvalidTokenError for a non-RSA key.
418+
419+
A subsequent call should re-fetch from the endpoint so that a key rotation
420+
is picked up without requiring a process restart.
421+
"""
422+
import jwt as pyjwt
423+
424+
strategy = _make_strategy()
425+
426+
ec_jwks = {
427+
"keys": [
428+
{
429+
"kid": "ec-kid",
430+
"kty": "EC",
431+
"crv": "P-256",
432+
"x": "abc",
433+
"y": "def",
434+
}
435+
]
436+
}
437+
438+
mock_response = MagicMock()
439+
mock_response.json.return_value = ec_jwks
440+
441+
with patch("requests.get", return_value=mock_response) as mock_get:
442+
# First call: fetches JWKS and raises because key type is not RSA.
443+
with pytest.raises(pyjwt.InvalidTokenError):
444+
await strategy._get_public_key("ec-kid")
445+
446+
# Cache entry must have been evicted on the error.
447+
assert strategy.jwks_url not in strategy._jwks_cache
448+
449+
# Second call: must re-fetch (not serve from cache).
450+
with pytest.raises(pyjwt.InvalidTokenError):
451+
await strategy._get_public_key("ec-kid")
452+
453+
assert mock_get.call_count == 2

0 commit comments

Comments
 (0)