Skip to content

Commit ba4853a

Browse files
committed
Throttle repeated PyJWKClient refreshes
1 parent 2798504 commit ba4853a

2 files changed

Lines changed: 192 additions & 12 deletions

File tree

jwt/jwks_client.py

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from __future__ import annotations
22

33
import json
4+
import math
5+
import threading
6+
import time
47
import urllib.request
58
from functools import lru_cache
69
from ssl import SSLContext
@@ -38,6 +41,7 @@ def __init__(
3841
headers: dict[str, Any] | None = None,
3942
timeout: float = 30,
4043
ssl_context: SSLContext | None = None,
44+
cooldown_duration: float = 30,
4145
):
4246
"""A client for retrieving signing keys from a JWKS endpoint.
4347
@@ -54,6 +58,14 @@ def __init__(
5458
- ``lifespan``: Time in seconds before the cached JWK Set expires.
5559
Defaults to ``300`` (5 minutes). Must be greater than 0.
5660
61+
Unknown key IDs can trigger one forced refresh after the cooldown
62+
period configured by ``cooldown_duration``. Every successful fetch
63+
starts this cooldown, including the initial fetch and cache-expiry
64+
fetches. A newly rotated key may therefore wait for the cooldown
65+
period before it is fetched; set ``cooldown_duration`` to ``0`` to
66+
disable this behavior. The cooldown is bypassed when
67+
``cache_jwk_set`` is ``False``.
68+
5769
**Tier 2 — Signing key cache** (disabled by default):
5870
Caches individual signing keys (looked up by ``kid``) using an LRU
5971
cache with **no time-based expiration**. Keys are evicted only when
@@ -80,6 +92,9 @@ def __init__(
8092
:type timeout: float
8193
:param ssl_context: Optional SSL context for the request.
8294
:type ssl_context: ssl.SSLContext or None
95+
:param cooldown_duration: Minimum time in seconds between forced
96+
refreshes after an unknown key ID. Defaults to ``30``.
97+
:type cooldown_duration: float
8398
"""
8499
if headers is None:
85100
headers = {}
@@ -98,6 +113,18 @@ def __init__(
98113
self.headers = headers
99114
self.timeout = timeout
100115
self.ssl_context = ssl_context
116+
if cooldown_duration < 0:
117+
raise PyJWKClientError(
118+
"Cooldown duration must be greater than or equal to 0, "
119+
f'the input is "{cooldown_duration}"'
120+
)
121+
if not math.isfinite(cooldown_duration):
122+
raise PyJWKClientError(
123+
f'Cooldown duration must be finite, the input is "{cooldown_duration}"'
124+
)
125+
self.cooldown_duration = cooldown_duration
126+
self._last_successful_fetch: float | None = None
127+
self._client_lock = threading.RLock()
101128

102129
if cache_jwk_set:
103130
# Init jwt set cache with default or given lifespan.
@@ -147,6 +174,7 @@ def fetch_data(self) -> Any:
147174
# wipe that breaks legitimate auth.
148175
if self.jwk_set_cache is not None:
149176
self.jwk_set_cache.put(jwk_set)
177+
self._last_successful_fetch = time.monotonic()
150178
return jwk_set
151179

152180
def get_jwk_set(self, refresh: bool = False) -> PyJWKSet:
@@ -186,6 +214,10 @@ def get_signing_keys(self, refresh: bool = False) -> list[PyJWK]:
186214
:raises PyJWKClientError: If no signing keys are found.
187215
"""
188216
jwk_set = self.get_jwk_set(refresh)
217+
return self._get_signing_keys_from_jwk_set(jwk_set)
218+
219+
@staticmethod
220+
def _get_signing_keys_from_jwk_set(jwk_set: PyJWKSet) -> list[PyJWK]:
189221
signing_keys = [
190222
jwk_set_key
191223
for jwk_set_key in jwk_set.keys
@@ -200,8 +232,9 @@ def get_signing_keys(self, refresh: bool = False) -> list[PyJWK]:
200232
def get_signing_key(self, kid: str) -> PyJWK:
201233
"""Return the signing key matching the given ``kid``.
202234
203-
If no match is found in the current JWK Set, the set is
204-
refreshed from the endpoint and the lookup is retried once.
235+
If no match is found in the current JWK Set, the set is refreshed
236+
from the endpoint and the lookup is retried once when the refresh
237+
cooldown permits it.
205238
206239
:param kid: The key ID to look up.
207240
:type kid: str
@@ -210,20 +243,28 @@ def get_signing_key(self, kid: str) -> PyJWK:
210243
:raises PyJWKClientError: If no matching key is found after
211244
refreshing.
212245
"""
213-
signing_keys = self.get_signing_keys()
214-
signing_key = self.match_kid(signing_keys, kid)
215-
216-
if not signing_key:
217-
# If no matching signing key from the jwk set, refresh the jwk set and try again.
218-
signing_keys = self.get_signing_keys(refresh=True)
246+
with self._client_lock:
247+
signing_keys = self.get_signing_keys()
219248
signing_key = self.match_kid(signing_keys, kid)
220249

221250
if not signing_key:
222-
raise PyJWKClientError(
223-
f'Unable to find a signing key that matches: "{kid}"'
251+
cooling_down = (
252+
self.jwk_set_cache is not None
253+
and self._last_successful_fetch is not None
254+
and time.monotonic() - self._last_successful_fetch
255+
< self.cooldown_duration
224256
)
257+
if not cooling_down:
258+
signing_keys = self.get_signing_keys(refresh=True)
259+
self._last_successful_fetch = time.monotonic()
260+
signing_key = self.match_kid(signing_keys, kid)
225261

226-
return signing_key
262+
if not signing_key:
263+
raise PyJWKClientError(
264+
f'Unable to find a signing key that matches: "{kid}"'
265+
)
266+
267+
return signing_key
227268

228269
def get_signing_key_from_jwt(self, token: str | bytes) -> PyJWK:
229270
"""Return the signing key for a JWT by reading its ``kid`` header.

tests/test_jwks_client.py

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,7 @@ def test_failed_request_should_raise_connection_error(self) -> None:
386386

387387
def test_get_jwt_set_refresh_cache(self) -> None:
388388
url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json"
389-
jwks_client = PyJWKClient(url)
389+
jwks_client = PyJWKClient(url, cooldown_duration=0)
390390

391391
kid = "NEE1QURBOTM4MzI5RkFDNTYxOTU1MDg2ODgwQ0UzMTk1QjYyRkRFQw"
392392

@@ -411,6 +411,145 @@ def test_get_jwt_set_no_matching_kid_after_second_attempt(self) -> None:
411411
):
412412
jwks_client.get_signing_key(kid)
413413

414+
def test_unknown_kid_refresh_is_cooled_down(self) -> None:
415+
url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json"
416+
kid = "unknown-kid"
417+
jwks_client = PyJWKClient(url, cooldown_duration=30)
418+
419+
with mock.patch("urllib.request.build_opener") as build_opener_mock:
420+
opener = mock.Mock()
421+
build_opener_mock.return_value = opener
422+
response = mock.Mock()
423+
response.__enter__ = mock.Mock(return_value=response)
424+
response.__exit__ = mock.Mock()
425+
response.read.return_value = json.dumps(RESPONSE_DATA_NO_MATCHING_KID)
426+
opener.open.return_value = response
427+
428+
for _ in range(2):
429+
with pytest.raises(PyJWKClientError, match="matches"):
430+
jwks_client.get_signing_key(kid)
431+
432+
assert opener.open.call_count == 1
433+
434+
def test_unknown_kid_refresh_runs_again_after_cooldown(self) -> None:
435+
url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json"
436+
kid = "unknown-kid"
437+
jwks_client = PyJWKClient(url, cooldown_duration=0.01)
438+
439+
with mock.patch("urllib.request.build_opener") as build_opener_mock:
440+
opener = mock.Mock()
441+
build_opener_mock.return_value = opener
442+
response = mock.Mock()
443+
response.__enter__ = mock.Mock(return_value=response)
444+
response.__exit__ = mock.Mock()
445+
response.read.return_value = json.dumps(RESPONSE_DATA_NO_MATCHING_KID)
446+
opener.open.return_value = response
447+
448+
clock = [0.0]
449+
with mock.patch(
450+
"jwt.jwks_client.time.monotonic", side_effect=lambda: clock[0]
451+
):
452+
with pytest.raises(PyJWKClientError, match="matches"):
453+
jwks_client.get_signing_key(kid)
454+
clock[0] = 0.02
455+
with pytest.raises(PyJWKClientError, match="matches"):
456+
jwks_client.get_signing_key(kid)
457+
458+
assert opener.open.call_count == 2
459+
460+
def test_unknown_kid_refresh_serializes_concurrent_misses(self) -> None:
461+
url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json"
462+
kid = "unknown-kid"
463+
jwks_client = PyJWKClient(url, cooldown_duration=30)
464+
refresh_started = threading.Event()
465+
second_done = threading.Event()
466+
release_refresh = threading.Event()
467+
468+
with mock.patch("urllib.request.build_opener") as build_opener_mock:
469+
opener = mock.Mock()
470+
build_opener_mock.return_value = opener
471+
response = mock.Mock()
472+
response.__enter__ = mock.Mock(return_value=response)
473+
response.__exit__ = mock.Mock()
474+
response.read.return_value = json.dumps(RESPONSE_DATA_NO_MATCHING_KID)
475+
476+
opener.open.return_value = response
477+
jwks_client.get_jwk_set()
478+
jwks_client._last_successful_fetch = 0
479+
480+
def open_response(*args: object, **kwargs: object) -> mock.Mock:
481+
if opener.open.call_count == 1:
482+
refresh_started.set()
483+
release_refresh.wait(timeout=5)
484+
return response
485+
486+
opener.open.reset_mock()
487+
opener.open.side_effect = open_response
488+
errors: list[Exception] = []
489+
490+
def lookup() -> None:
491+
try:
492+
jwks_client.get_signing_key(kid)
493+
except PyJWKClientError as error:
494+
errors.append(error)
495+
496+
first = threading.Thread(target=lookup)
497+
498+
def second_lookup() -> None:
499+
lookup()
500+
second_done.set()
501+
502+
second = threading.Thread(target=second_lookup)
503+
with mock.patch("jwt.jwks_client.time.monotonic", return_value=31):
504+
first.start()
505+
assert refresh_started.wait(timeout=5)
506+
second.start()
507+
assert not second_done.wait(timeout=0.1)
508+
release_refresh.set()
509+
first.join(timeout=5)
510+
second.join(timeout=5)
511+
512+
assert len(errors) == 2
513+
assert not first.is_alive()
514+
assert not second.is_alive()
515+
assert opener.open.call_count == 1
516+
517+
def test_unknown_kid_refresh_ignores_cooldown_when_cache_disabled(self) -> None:
518+
url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json"
519+
kid = "unknown-kid"
520+
jwks_client = PyJWKClient(url, cache_jwk_set=False, cooldown_duration=30)
521+
522+
with mock.patch("urllib.request.build_opener") as build_opener_mock:
523+
opener = mock.Mock()
524+
build_opener_mock.return_value = opener
525+
response = mock.Mock()
526+
response.__enter__ = mock.Mock(return_value=response)
527+
response.__exit__ = mock.Mock()
528+
response.read.side_effect = [
529+
json.dumps(RESPONSE_DATA_NO_MATCHING_KID),
530+
json.dumps(RESPONSE_DATA_NO_MATCHING_KID),
531+
json.dumps(RESPONSE_DATA_NO_MATCHING_KID),
532+
json.dumps(RESPONSE_DATA_NO_MATCHING_KID),
533+
]
534+
opener.open.return_value = response
535+
536+
for _ in range(2):
537+
with pytest.raises(PyJWKClientError, match="matches"):
538+
jwks_client.get_signing_key(kid)
539+
540+
assert opener.open.call_count == 4
541+
542+
@pytest.mark.parametrize(
543+
"cooldown_duration", [float("nan"), float("inf"), float("-inf")]
544+
)
545+
def test_unknown_kid_refresh_rejects_non_finite_cooldown(
546+
self, cooldown_duration: float
547+
) -> None:
548+
url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json"
549+
550+
with pytest.raises(PyJWKClientError, match="Cooldown duration"):
551+
PyJWKClient(url, cooldown_duration=cooldown_duration)
552+
414553
def test_get_jwt_set_invalid_lifespan(self) -> None:
415554
url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json"
416555

0 commit comments

Comments
 (0)