From cb19f697eff2807d28c66639e5ee39cc92de4985 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Padilla?= Date: Mon, 3 Aug 2026 03:21:01 -0700 Subject: [PATCH] PyJWKClient: preserve cached JWKS on fetch errors Only replace the cached JWK set after a successful fetch. A failed refresh previously stored None from the finally block and discarded valid cached keys. CVE: CVE-2026-48524 Upstream-Status: Backport [https://github.com/jpadilla/pyjwt/commit/95791b1759b8aa4f2203575d344d5c78564cdc81] Backport Changes: - Extracted only the CVE-2026-48524 successful-fetch cache update and regression test from the bundled upstream 2.13.0 commit. The other requested CVE fixes are carried as separate patches. - Adapted the hunk and test locations to PyJWT 2.8.0 and shortened the upstream explanatory comments without changing behavior or assertions. - Omitted the 2.13.0 version and changelog updates, CVE-2026-48523 (which does not affect 2.8.0), and unrelated hardening from the bundled commit. (cherry picked from commit 95791b1759b8aa4f2203575d344d5c78564cdc81) Signed-off-by: Hetvi Thakar --- jwt/jwks_client.py | 12 ++++++------ tests/test_jwks_client.py | 12 ++++++++++-- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/jwt/jwks_client.py b/jwt/jwks_client.py index 18de342..0e7d226 100644 --- a/jwt/jwks_client.py +++ b/jwt/jwks_client.py @@ -59,7 +59,6 @@ class PyJWKClient: self.get_signing_key = lru_cache(maxsize=max_cached_keys)(self.get_signing_key) # type: ignore def fetch_data(self) -> Any: - jwk_set: Any = None try: r = urllib.request.Request(url=self.uri, headers=self.headers) with urllib.request.urlopen( @@ -70,11 +69,12 @@ class PyJWKClient: raise PyJWKClientConnectionError( f'Fail to fetch data from the url, err: "{e}"' ) - else: - return jwk_set - finally: - if self.jwk_set_cache is not None: - self.jwk_set_cache.put(jwk_set) + + # Only update the cache on a successful fetch. Writing None from a + # finally block on error would discard a previously cached JWKS. + if self.jwk_set_cache is not None: + self.jwk_set_cache.put(jwk_set) + return jwk_set def get_jwk_set(self, refresh: bool = False) -> PyJWKSet: data = None diff --git a/tests/test_jwks_client.py b/tests/test_jwks_client.py index d4bdd35..4a836f2 100644 --- a/tests/test_jwks_client.py +++ b/tests/test_jwks_client.py @@ -271,18 +271,26 @@ class TestPyJWKClient: assert repeated_call.call_count == 1 - def test_get_jwt_set_failed_request_should_clear_cache(self): + def test_get_jwt_set_failed_refresh_preserves_cached_jwks(self) -> None: url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json" jwks_client = PyJWKClient(url) with mocked_success_response(RESPONSE_DATA_WITH_MATCHING_KID): jwks_client.get_jwk_set() + assert jwks_client.jwk_set_cache is not None + assert jwks_client.jwk_set_cache.get() is not None + with pytest.raises(PyJWKClientError): with mocked_failed_response(): jwks_client.get_jwk_set(refresh=True) - assert jwks_client.jwk_set_cache is None + cached = jwks_client.jwk_set_cache.get() + assert cached is not None + + with mocked_success_response(RESPONSE_DATA_WITH_MATCHING_KID) as call: + jwks_client.get_jwk_set() + assert call.call_count == 0 def test_failed_request_should_raise_connection_error(self): token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ik5FRTFRVVJCT1RNNE16STVSa0ZETlRZeE9UVTFNRGcyT0Rnd1EwVXpNVGsxUWpZeVJrUkZRdyJ9.eyJpc3MiOiJodHRwczovL2Rldi04N2V2eDlydS5hdXRoMC5jb20vIiwic3ViIjoiYVc0Q2NhNzl4UmVMV1V6MGFFMkg2a0QwTzNjWEJWdENAY2xpZW50cyIsImF1ZCI6Imh0dHBzOi8vZXhwZW5zZXMtYXBpIiwiaWF0IjoxNTcyMDA2OTU0LCJleHAiOjE1NzIwMDY5NjQsImF6cCI6ImFXNENjYTc5eFJlTFdVejBhRTJINmtEME8zY1hCVnRDIiwiZ3R5IjoiY2xpZW50LWNyZWRlbnRpYWxzIn0.PUxE7xn52aTCohGiWoSdMBZGiYAHwE5FYie0Y1qUT68IHSTXwXVd6hn02HTah6epvHHVKA2FqcFZ4GGv5VTHEvYpeggiiZMgbxFrmTEY0csL6VNkX1eaJGcuehwQCRBKRLL3zKmA5IKGy5GeUnIbpPHLHDxr-GXvgFzsdsyWlVQvPX2xjeaQ217r2PtxDeqjlf66UYl6oY6AqNS8DH3iryCvIfCcybRZkc_hdy-6ZMoKT6Piijvk_aXdm7-QQqKJFHLuEqrVSOuBqqiNfVrG27QzAPuPOxvfXTVLXL2jek5meH6n-VWgrBdoMFH93QEszEDowDAEhQPHVs0xj7SIzA"