Repository: jpadilla/pyjwt
Pull request #611 (2021-02-02 22:11:46)

Cache signing keys
The function `get_signing_key` indirectly makes a network call to get the public key. For a project that authenticates requests extremely often, this introduces a *lot* of delay. 

Cache the result of `get_signing_key` so that it gets reused if called with ~~the same client and~~ the same `kid`. 

This should *not* break any projects that are following JWT standards, as while the RFC is [not entirely explicit](https://tools.ietf.org/html/rfc7515#section-4.1.4), common practice dictates that [the `kid` must be unique](https://auth0.com/blog/navigating-rs256-and-jwks/):
> kid: is the unique identifier for the key

Therefore, unless there is somehow a new key added and an old key removed which both somehow have the same `kid` (which I think is a fair assumption), this is not a breaking change. 

~~I initially wanted to use `functools.lru_cache`, but there are [known issues](https://stackoverflow.com/questions/33672412) with using `functools.lru_cache` with instance methods.~~

~~The `_known_signing_key` key values include the URI, so as not to break any existing workflows that change an instance of PyJWKClient's uri value (for whatever reason). I'm honestly fine with getting rid of the URI part so that it's just kid -> key, but I figure you guys should have the say on that decision :shrug:~~

~~I've also added a test to check that the caching is working, and a test to make sure that we're not overwriting keys from different URIs. During testing, I noticed that `mocked_response` only allows `urllib.request.urlopen` to be called once, so technically `test_get_signing_key_caches_result` will fail with an exception instead of `network_response.call_count == 1`, but it still fails if the caching isn't working.~~


Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 1fb0890..4a5b5ee 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -6,7 +6,7 @@ repos:
         args: ["--target-version=py36"]
 
   - repo: https://github.com/asottile/blacken-docs
-    rev: v1.9.1
+    rev: v1.9.2
     hooks:
       - id: blacken-docs
         args: ["--target-version=py36"]
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 4712e2e..42864cb 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -10,12 +10,16 @@ This project adheres to `Semantic Versioning <https://semver.org/>`__.
 Changed
 ~~~~~~~
 
+- Allow claims validation without making JWT signature validation mandatory. `#608 <https://github.com/jpadilla/pyjwt/pull/608>`__
+
 Fixed
 ~~~~~
 
 Added
 ~~~~~
 
+- Add caching by default to PyJWKClient `#611 <https://github.com/jpadilla/pyjwt/pull/611>`__
+
 `v2.0.1 <https://github.com/jpadilla/pyjwt/compare/2.0.0...2.0.1>`__
 --------------------------------------------------------------------
 
diff --git a/jwt/api_jwt.py b/jwt/api_jwt.py
index 80b33bf..70a5e53 100644
--- a/jwt/api_jwt.py
+++ b/jwt/api_jwt.py
@@ -75,6 +75,13 @@ class PyJWT:
         else:
             options.setdefault("verify_signature", True)
 
+        if not options["verify_signature"]:
+            options.setdefault("verify_exp", False)
+            options.setdefault("verify_nbf", False)
+            options.setdefault("verify_iat", False)
+            options.setdefault("verify_aud", False)
+            options.setdefault("verify_iss", False)
+
         if options["verify_signature"] and not algorithms:
             raise DecodeError(
                 'It is required that you pass in a value for the "algorithms" argument when calling decode().'
@@ -95,9 +102,8 @@ class PyJWT:
         if not isinstance(payload, dict):
             raise DecodeError("Invalid payload string: must be a json object")
 
-        if options["verify_signature"]:
-            merged_options = {**self.options, **options}
-            self._validate_claims(payload, merged_options, **kwargs)
+        merged_options = {**self.options, **options}
+        self._validate_claims(payload, merged_options, **kwargs)
 
         decoded["payload"] = payload
         return decoded
diff --git a/jwt/jwks_client.py b/jwt/jwks_client.py
index a6c0d9f..dc86c3b 100644
--- a/jwt/jwks_client.py
+++ b/jwt/jwks_client.py
@@ -1,5 +1,6 @@
 import json
 import urllib.request
+from functools import lru_cache
 from typing import Any, List
 
 from .api_jwk import PyJWK, PyJWKSet
@@ -8,8 +9,12 @@ from .exceptions import PyJWKClientError
 
 
 class PyJWKClient:
-    def __init__(self, uri: str):
+    def __init__(self, uri: str, cache_keys: bool = True, max_cached_keys: int = 16):
         self.uri = uri
+        if cache_keys:
+            # Cache signing keys
+            # Ignore mypy (https://github.com/python/mypy/issues/2427)
+            self.get_signing_key = lru_cache(maxsize=max_cached_keys)(self.get_signing_key)  # type: ignore
 
     def fetch_data(self) -> Any:
         with urllib.request.urlopen(self.uri) as response:


Test patch:
diff --git a/tests/test_api_jwt.py b/tests/test_api_jwt.py
index f161763..05cb714 100644
--- a/tests/test_api_jwt.py
+++ b/tests/test_api_jwt.py
@@ -579,6 +579,22 @@ class TestJWT:
                 options={"verify_exp": True},
             )
 
+    def test_decode_with_verify_exp_option_and_signature_off(self, jwt, payload):
+        payload["exp"] = utc_timestamp() - 1
+        secret = "secret"
+        jwt_message = jwt.encode(payload, secret)
+
+        jwt.decode(
+            jwt_message,
+            options={"verify_signature": False},
+        )
+
+        with pytest.raises(ExpiredSignatureError):
+            jwt.decode(
+                jwt_message,
+                options={"verify_signature": False, "verify_exp": True},
+            )
+
     def test_decode_with_optional_algorithms(self, jwt, payload):
         secret = "secret"
         jwt_message = jwt.encode(payload, secret)
diff --git a/tests/test_jwks_client.py b/tests/test_jwks_client.py
index 7c3dde4..a512200 100644
--- a/tests/test_jwks_client.py
+++ b/tests/test_jwks_client.py
@@ -37,7 +37,7 @@ def mocked_response(data):
         response.__exit__ = mock.Mock()
         response.read.side_effect = [json.dumps(data)]
         urlopen_mock.return_value = response
-        yield
+        yield urlopen_mock
 
 
 @crypto_required
@@ -88,6 +88,38 @@ class TestPyJWKClient:
         assert signing_key.key_id == kid
         assert signing_key.public_key_use == "sig"
 
+    def test_get_signing_key_caches_result(self):
+        url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json"
+        kid = "NEE1QURBOTM4MzI5RkFDNTYxOTU1MDg2ODgwQ0UzMTk1QjYyRkRFQw"
+
+        jwks_client = PyJWKClient(url)
+
+        with mocked_response(RESPONSE_DATA):
+            jwks_client.get_signing_key(kid)
+
+        # mocked_response does not allow urllib.request.urlopen to be called twice
+        # so a second mock is needed
+        with mocked_response(RESPONSE_DATA) as repeated_call:
+            jwks_client.get_signing_key(kid)
+
+        assert repeated_call.call_count == 0
+
+    def test_get_signing_key_does_not_cache_opt_out(self):
+        url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json"
+        kid = "NEE1QURBOTM4MzI5RkFDNTYxOTU1MDg2ODgwQ0UzMTk1QjYyRkRFQw"
+
+        jwks_client = PyJWKClient(url, cache_keys=False)
+
+        with mocked_response(RESPONSE_DATA):
+            jwks_client.get_signing_key(kid)
+
+        # mocked_response does not allow urllib.request.urlopen to be called twice
+        # so a second mock is needed
+        with mocked_response(RESPONSE_DATA) as repeated_call:
+            jwks_client.get_signing_key(kid)
+
+        assert repeated_call.call_count == 1
+
     def test_get_signing_key_from_jwt(self):
         token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ik5FRTFRVVJCT1RNNE16STVSa0ZETlRZeE9UVTFNRGcyT0Rnd1EwVXpNVGsxUWpZeVJrUkZRdyJ9.eyJpc3MiOiJodHRwczovL2Rldi04N2V2eDlydS5hdXRoMC5jb20vIiwic3ViIjoiYVc0Q2NhNzl4UmVMV1V6MGFFMkg2a0QwTzNjWEJWdENAY2xpZW50cyIsImF1ZCI6Imh0dHBzOi8vZXhwZW5zZXMtYXBpIiwiaWF0IjoxNTcyMDA2OTU0LCJleHAiOjE1NzIwMDY5NjQsImF6cCI6ImFXNENjYTc5eFJlTFdVejBhRTJINmtEME8zY1hCVnRDIiwiZ3R5IjoiY2xpZW50LWNyZWRlbnRpYWxzIn0.PUxE7xn52aTCohGiWoSdMBZGiYAHwE5FYie0Y1qUT68IHSTXwXVd6hn02HTah6epvHHVKA2FqcFZ4GGv5VTHEvYpeggiiZMgbxFrmTEY0csL6VNkX1eaJGcuehwQCRBKRLL3zKmA5IKGy5GeUnIbpPHLHDxr-GXvgFzsdsyWlVQvPX2xjeaQ217r2PtxDeqjlf66UYl6oY6AqNS8DH3iryCvIfCcybRZkc_hdy-6ZMoKT6Piijvk_aXdm7-QQqKJFHLuEqrVSOuBqqiNfVrG27QzAPuPOxvfXTVLXL2jek5meH6n-VWgrBdoMFH93QEszEDowDAEhQPHVs0xj7SIzA"
         url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json"