Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/jwt/jwks_client.py: 27%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1from __future__ import annotations
3import json
4import math
5import threading
6import time
7import urllib.request
8from functools import lru_cache
9from ssl import SSLContext
10from typing import Any
11from urllib.error import HTTPError, URLError
12from urllib.parse import urlparse
14from .api_jwk import PyJWK, PyJWKSet
15from .api_jwt import decode_complete as decode_token
16from .exceptions import PyJWKClientConnectionError, PyJWKClientError
17from .jwk_set_cache import JWKSetCache
20class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
21 def redirect_request(
22 self,
23 req: urllib.request.Request,
24 fp: Any,
25 code: int,
26 msg: str,
27 headers: Any,
28 newurl: str,
29 ) -> urllib.request.Request | None:
30 return None
33class PyJWKClient:
34 def __init__(
35 self,
36 uri: str,
37 cache_keys: bool = False,
38 max_cached_keys: int = 16,
39 cache_jwk_set: bool = True,
40 lifespan: float = 300,
41 headers: dict[str, Any] | None = None,
42 timeout: float = 30,
43 ssl_context: SSLContext | None = None,
44 cooldown_duration: float = 30,
45 ):
46 """A client for retrieving signing keys from a JWKS endpoint.
48 ``PyJWKClient`` uses a two-tier caching system to avoid unnecessary
49 network requests:
51 **Tier 1 — JWK Set cache** (enabled by default):
52 Caches the entire JSON Web Key Set response from the endpoint.
53 Controlled by:
55 - ``cache_jwk_set``: Set to ``True`` (the default) to enable this
56 cache. When enabled, the JWK Set is fetched from the network only
57 when the cache is empty or expired.
58 - ``lifespan``: Time in seconds before the cached JWK Set expires.
59 Defaults to ``300`` (5 minutes). Must be greater than 0.
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``.
69 **Tier 2 — Signing key cache** (disabled by default):
70 Caches individual signing keys (looked up by ``kid``) using an LRU
71 cache with **no time-based expiration**. Keys are evicted only when
72 the cache reaches its maximum size. Controlled by:
74 - ``cache_keys``: Set to ``True`` to enable this cache.
75 Defaults to ``False``.
76 - ``max_cached_keys``: Maximum number of signing keys to keep in
77 the LRU cache. Defaults to ``16``.
79 :param uri: The URL of the JWKS endpoint.
80 :type uri: str
81 :param cache_keys: Enable the per-key LRU cache (Tier 2).
82 :type cache_keys: bool
83 :param max_cached_keys: Max entries in the signing key LRU cache.
84 :type max_cached_keys: int
85 :param cache_jwk_set: Enable the JWK Set response cache (Tier 1).
86 :type cache_jwk_set: bool
87 :param lifespan: TTL in seconds for the JWK Set cache.
88 :type lifespan: float
89 :param headers: Optional HTTP headers to include in requests.
90 :type headers: dict or None
91 :param timeout: HTTP request timeout in seconds.
92 :type timeout: float
93 :param ssl_context: Optional SSL context for the request.
94 :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
98 """
99 if headers is None:
100 headers = {}
101 # urllib's default OpenerDirector also handles file://, ftp://, and
102 # data: URIs. Reject anything that isn't http(s) eagerly so a caller
103 # passing an attacker-influenced URL (e.g. taken from a `jku` token
104 # header) can't read local files or reach other unintended schemes.
105 scheme = urlparse(uri).scheme.lower()
106 if scheme not in ("http", "https"):
107 raise PyJWKClientError(
108 f"Invalid JWKS URI scheme {scheme!r}: only 'http' and 'https' "
109 f"are supported."
110 )
111 self.uri = uri
112 self.jwk_set_cache: JWKSetCache | None = None
113 self.headers = headers
114 self.timeout = timeout
115 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()
129 if cache_jwk_set:
130 # Init jwt set cache with default or given lifespan.
131 # Default lifespan is 300 seconds (5 minutes).
132 if lifespan <= 0:
133 raise PyJWKClientError(
134 f'Lifespan must be greater than 0, the input is "{lifespan}"'
135 )
136 self.jwk_set_cache = JWKSetCache(lifespan)
137 else:
138 self.jwk_set_cache = None
140 if cache_keys:
141 # Cache signing keys
142 get_signing_key = lru_cache(maxsize=max_cached_keys)(self.get_signing_key)
143 # Ignore mypy (https://github.com/python/mypy/issues/2427)
144 self.get_signing_key = get_signing_key # type: ignore[method-assign]
146 def fetch_data(self) -> Any:
147 """Fetch the JWK Set from the JWKS endpoint.
149 Makes an HTTP request to the configured ``uri`` and returns the
150 parsed JSON response. If the JWK Set cache is enabled, the
151 response is stored in the cache.
153 :returns: The parsed JWK Set as a dictionary.
154 :raises PyJWKClientConnectionError: If the HTTP request fails.
155 """
156 try:
157 r = urllib.request.Request(url=self.uri, headers=self.headers)
158 handlers: list[Any] = [_NoRedirectHandler()]
159 if self.ssl_context is not None:
160 handlers.append(urllib.request.HTTPSHandler(context=self.ssl_context))
161 opener = urllib.request.build_opener(*handlers)
162 with opener.open(r, timeout=self.timeout) as response:
163 jwk_set = json.load(response)
164 except (URLError, TimeoutError) as e:
165 if isinstance(e, HTTPError):
166 e.close()
167 raise PyJWKClientConnectionError(
168 f'Fail to fetch data from the url, err: "{e}"'
169 ) from e
171 # Only update the cache on a successful fetch. Writing in a
172 # `finally` block with `jwk_set=None` on error clears any
173 # previously-cached JWKS, turning a transient outage into a cache
174 # wipe that breaks legitimate auth.
175 if self.jwk_set_cache is not None:
176 self.jwk_set_cache.put(jwk_set)
177 self._last_successful_fetch = time.monotonic()
178 return jwk_set
180 def get_jwk_set(self, refresh: bool = False) -> PyJWKSet:
181 """Return the JWK Set, using the cache when available.
183 :param refresh: Force a fresh fetch from the endpoint, bypassing
184 the cache.
185 :type refresh: bool
186 :returns: The JWK Set.
187 :rtype: PyJWKSet
188 :raises PyJWKClientError: If the endpoint does not return a JSON
189 object.
190 """
191 data = None
192 if self.jwk_set_cache is not None and not refresh:
193 data = self.jwk_set_cache.get()
195 if data is None:
196 data = self.fetch_data()
198 if not isinstance(data, dict):
199 raise PyJWKClientError("The JWKS endpoint did not return a JSON object")
201 return PyJWKSet.from_dict(data)
203 def get_signing_keys(self, refresh: bool = False) -> list[PyJWK]:
204 """Return all signing keys from the JWK Set.
206 Filters the JWK Set to keys whose ``use`` is ``"sig"`` (or
207 unspecified) and that have a ``kid``.
209 :param refresh: Force a fresh fetch from the endpoint, bypassing
210 the cache.
211 :type refresh: bool
212 :returns: A list of signing keys.
213 :rtype: list[PyJWK]
214 :raises PyJWKClientError: If no signing keys are found.
215 """
216 jwk_set = self.get_jwk_set(refresh)
217 return self._get_signing_keys_from_jwk_set(jwk_set)
219 @staticmethod
220 def _get_signing_keys_from_jwk_set(jwk_set: PyJWKSet) -> list[PyJWK]:
221 signing_keys = [
222 jwk_set_key
223 for jwk_set_key in jwk_set.keys
224 if jwk_set_key.public_key_use in ["sig", None] and jwk_set_key.key_id
225 ]
227 if not signing_keys:
228 raise PyJWKClientError("The JWKS endpoint did not contain any signing keys")
230 return signing_keys
232 def get_signing_key(self, kid: str) -> PyJWK:
233 """Return the signing key matching the given ``kid``.
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.
239 :param kid: The key ID to look up.
240 :type kid: str
241 :returns: The matching signing key.
242 :rtype: PyJWK
243 :raises PyJWKClientError: If no matching key is found after
244 refreshing.
245 """
246 with self._client_lock:
247 signing_keys = self.get_signing_keys()
248 signing_key = self.match_kid(signing_keys, kid)
250 if not signing_key:
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
256 )
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)
262 if not signing_key:
263 raise PyJWKClientError(
264 f'Unable to find a signing key that matches: "{kid}"'
265 )
267 return signing_key
269 def get_signing_key_from_jwt(self, token: str | bytes) -> PyJWK:
270 """Return the signing key for a JWT by reading its ``kid`` header.
272 Extracts the ``kid`` from the token's unverified header and
273 delegates to :meth:`get_signing_key`.
275 :param token: The encoded JWT.
276 :type token: str or bytes
277 :returns: The matching signing key.
278 :rtype: PyJWK
279 """
280 unverified = decode_token(token, options={"verify_signature": False})
281 header = unverified["header"]
282 return self.get_signing_key(header.get("kid"))
284 @staticmethod
285 def match_kid(signing_keys: list[PyJWK], kid: str) -> PyJWK | None:
286 """Find a key in *signing_keys* that matches *kid*.
288 :param signing_keys: The list of keys to search.
289 :type signing_keys: list[PyJWK]
290 :param kid: The key ID to match.
291 :type kid: str
292 :returns: The matching key, or ``None`` if not found.
293 :rtype: PyJWK or None
294 """
295 signing_key = None
297 for key in signing_keys:
298 if key.key_id == kid:
299 signing_key = key
300 break
302 return signing_key