Skip to content

Commit 46df6c3

Browse files
authored
Merge pull request #68 from IdentityPython/develop
Prepare release 1.3.0
2 parents 3f24ac8 + dcad904 commit 46df6c3

File tree

6 files changed

+102
-30
lines changed

6 files changed

+102
-30
lines changed

src/cryptojwt/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
except ImportError:
2222
pass
2323

24-
__version__ = "1.2.0"
24+
__version__ = "1.3.0"
2525

2626
logger = logging.getLogger(__name__)
2727

src/cryptojwt/exception.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,6 @@ class UpdateFailed(KeyIOError):
6363
pass
6464

6565

66-
class UnknownKeytype(Invalid):
67-
"""An unknown key type"""
68-
69-
7066
class JWKException(JWKESTException):
7167
pass
7268

src/cryptojwt/key_bundle.py

Lines changed: 50 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import logging
55
import os
66
import time
7+
from datetime import datetime
78
from functools import cmp_to_key
89

910
import requests
@@ -156,6 +157,7 @@ def __init__(
156157
keys=None,
157158
source="",
158159
cache_time=300,
160+
ignore_errors_period=0,
159161
fileformat="jwks",
160162
keytype="RSA",
161163
keyusage=None,
@@ -188,6 +190,8 @@ def __init__(
188190
self.remote = False
189191
self.local = False
190192
self.cache_time = cache_time
193+
self.ignore_errors_period = ignore_errors_period
194+
self.ignore_errors_until = None # UNIX timestamp of last error
191195
self.time_out = 0
192196
self.etag = ""
193197
self.source = None
@@ -314,7 +318,11 @@ def do_local_jwk(self, filename):
314318
Load a JWKS from a local file
315319
316320
:param filename: Name of the file from which the JWKS should be loaded
321+
:return: True if load was successful or False if file hasn't been modified
317322
"""
323+
if not self._local_update_required():
324+
return False
325+
318326
LOGGER.info("Reading local JWKS from %s", filename)
319327
with open(filename) as input_file:
320328
_info = json.load(input_file)
@@ -324,6 +332,7 @@ def do_local_jwk(self, filename):
324332
self.do_keys([_info])
325333
self.last_local = time.time()
326334
self.time_out = self.last_local + self.cache_time
335+
return True
327336

328337
def do_local_der(self, filename, keytype, keyusage=None, kid=""):
329338
"""
@@ -332,7 +341,11 @@ def do_local_der(self, filename, keytype, keyusage=None, kid=""):
332341
:param filename: Name of the file
333342
:param keytype: Presently 'rsa' and 'ec' supported
334343
:param keyusage: encryption ('enc') or signing ('sig') or both
344+
:return: True if load was successful or False if file hasn't been modified
335345
"""
346+
if not self._local_update_required():
347+
return False
348+
336349
LOGGER.info("Reading local DER from %s", filename)
337350
key_args = {}
338351
_kty = keytype.lower()
@@ -355,16 +368,25 @@ def do_local_der(self, filename, keytype, keyusage=None, kid=""):
355368
self.do_keys([key_args])
356369
self.last_local = time.time()
357370
self.time_out = self.last_local + self.cache_time
371+
return True
358372

359373
def do_remote(self):
360374
"""
361375
Load a JWKS from a webpage.
362376
363-
:return: True or False if load was successful
377+
:return: True if load was successful or False if remote hasn't been modified
364378
"""
365379
# if self.verify_ssl is not None:
366380
# self.httpc_params["verify"] = self.verify_ssl
367381

382+
if self.ignore_errors_until and time.time() < self.ignore_errors_until:
383+
LOGGER.warning(
384+
"Not reading remote JWKS from %s (in error holddown until %s)",
385+
self.source,
386+
datetime.fromtimestamp(self.ignore_errors_until),
387+
)
388+
return False
389+
368390
LOGGER.info("Reading remote JWKS from %s", self.source)
369391
try:
370392
LOGGER.debug("KeyBundle fetch keys from: %s", self.source)
@@ -378,7 +400,10 @@ def do_remote(self):
378400
LOGGER.error(err)
379401
raise UpdateFailed(REMOTE_FAILED.format(self.source, str(err)))
380402

381-
if _http_resp.status_code == 200: # New content
403+
load_successful = _http_resp.status_code == 200
404+
not_modified = _http_resp.status_code == 304
405+
406+
if load_successful:
382407
self.time_out = time.time() + self.cache_time
383408

384409
self.imp_jwks = self._parse_remote_response(_http_resp)
@@ -390,25 +415,27 @@ def do_remote(self):
390415
self.do_keys(self.imp_jwks["keys"])
391416
except KeyError:
392417
LOGGER.error("No 'keys' keyword in JWKS")
418+
self.ignore_errors_until = time.time() + self.ignore_errors_period
393419
raise UpdateFailed(MALFORMED.format(self.source))
394420

395421
if hasattr(_http_resp, "headers"):
396422
headers = getattr(_http_resp, "headers")
397423
self.last_remote = headers.get("last-modified") or headers.get("date")
398-
399-
elif _http_resp.status_code == 304: # Not modified
424+
elif not_modified:
400425
LOGGER.debug("%s not modified since %s", self.source, self.last_remote)
401426
self.time_out = time.time() + self.cache_time
402-
403427
else:
404428
LOGGER.warning(
405429
"HTTP status %d reading remote JWKS from %s",
406430
_http_resp.status_code,
407431
self.source,
408432
)
433+
self.ignore_errors_until = time.time() + self.ignore_errors_period
409434
raise UpdateFailed(REMOTE_FAILED.format(self.source, _http_resp.status_code))
435+
410436
self.last_updated = time.time()
411-
return True
437+
self.ignore_errors_until = None
438+
return load_successful
412439

413440
def _parse_remote_response(self, response):
414441
"""
@@ -433,23 +460,20 @@ def _parse_remote_response(self, response):
433460
return None
434461

435462
def _uptodate(self):
436-
res = False
437463
if self.remote or self.local:
438464
if time.time() > self.time_out:
439-
if self.local and not self._local_update_required():
440-
res = True
441-
elif self.update():
442-
res = True
443-
return res
465+
return self.update()
466+
return False
444467

445468
def update(self):
446469
"""
447470
Reload the keys if necessary.
448471
449472
This is a forced update, will happen even if cache time has not elapsed.
450473
Replaced keys will be marked as inactive and not removed.
474+
475+
:return: True if update was ok or False if we encountered an error during update.
451476
"""
452-
res = True # An update was successful
453477
if self.source:
454478
_old_keys = self._keys # just in case
455479

@@ -459,24 +483,27 @@ def update(self):
459483
try:
460484
if self.local:
461485
if self.fileformat in ["jwks", "jwk"]:
462-
self.do_local_jwk(self.source)
486+
updated = self.do_local_jwk(self.source)
463487
elif self.fileformat == "der":
464-
self.do_local_der(self.source, self.keytype, self.keyusage)
488+
updated = self.do_local_der(self.source, self.keytype, self.keyusage)
465489
elif self.remote:
466-
res = self.do_remote()
490+
updated = self.do_remote()
467491
except Exception as err:
468492
LOGGER.error("Key bundle update failed: %s", err)
469493
self._keys = _old_keys # restore
470494
return False
471495

472-
now = time.time()
473-
for _key in _old_keys:
474-
if _key not in self._keys:
475-
if not _key.inactive_since: # If already marked don't mess
476-
_key.inactive_since = now
477-
self._keys.append(_key)
496+
if updated:
497+
now = time.time()
498+
for _key in _old_keys:
499+
if _key not in self._keys:
500+
if not _key.inactive_since: # If already marked don't mess
501+
_key.inactive_since = now
502+
self._keys.append(_key)
503+
else:
504+
self._keys = _old_keys
478505

479-
return res
506+
return True
480507

481508
def get(self, typ="", only_active=True):
482509
"""

tests/test_03_key_bundle.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from cryptojwt.jwk.rsa import import_rsa_key_from_cert_file
1818
from cryptojwt.jwk.rsa import new_rsa_key
1919
from cryptojwt.key_bundle import KeyBundle
20+
from cryptojwt.key_bundle import UpdateFailed
2021
from cryptojwt.key_bundle import build_key_bundle
2122
from cryptojwt.key_bundle import dump_jwks
2223
from cryptojwt.key_bundle import init_key
@@ -566,6 +567,7 @@ def test_update_2():
566567
ec_key = new_ec_key(crv="P-256", key_ops=["sign"])
567568
_jwks = {"keys": [rsa_key.serialize(), ec_key.serialize()]}
568569

570+
time.sleep(0.5)
569571
with open(fname, "w") as fp:
570572
fp.write(json.dumps(_jwks))
571573

@@ -1008,7 +1010,7 @@ def test_remote_not_modified():
10081010

10091011
with responses.RequestsMock() as rsps:
10101012
rsps.add(method="GET", url=source, status=304, headers=headers)
1011-
assert kb.do_remote()
1013+
assert not kb.do_remote()
10121014
assert kb.last_remote == headers.get("Last-Modified")
10131015
timeout2 = kb.time_out
10141016

@@ -1018,9 +1020,50 @@ def test_remote_not_modified():
10181020
kb2 = KeyBundle().load(exp)
10191021
assert kb2.source == source
10201022
assert len(kb2.keys()) == 3
1023+
assert len(kb2.active_keys()) == 3
10211024
assert len(kb2.get("rsa")) == 1
10221025
assert len(kb2.get("oct")) == 1
10231026
assert len(kb2.get("ec")) == 1
10241027
assert kb2.httpc_params == {"timeout": (2, 2)}
10251028
assert kb2.imp_jwks
10261029
assert kb2.last_updated
1030+
1031+
1032+
def test_ignore_errors_period():
1033+
source_good = "https://example.com/keys.json"
1034+
source_bad = "https://example.com/keys-bad.json"
1035+
ignore_errors_period = 1
1036+
# Mock response
1037+
with responses.RequestsMock() as rsps:
1038+
rsps.add(method="GET", url=source_good, json=JWKS_DICT, status=200)
1039+
rsps.add(method="GET", url=source_bad, json=JWKS_DICT, status=500)
1040+
httpc_params = {"timeout": (2, 2)} # connect, read timeouts in seconds
1041+
kb = KeyBundle(
1042+
source=source_good,
1043+
httpc=requests.request,
1044+
httpc_params=httpc_params,
1045+
ignore_errors_period=ignore_errors_period,
1046+
)
1047+
res = kb.do_remote()
1048+
assert res == True
1049+
assert kb.ignore_errors_until is None
1050+
1051+
# refetch, but fail by using a bad source
1052+
kb.source = source_bad
1053+
try:
1054+
res = kb.do_remote()
1055+
except UpdateFailed:
1056+
pass
1057+
1058+
# retry should fail silently as we're in holddown
1059+
res = kb.do_remote()
1060+
assert kb.ignore_errors_until is not None
1061+
assert res == False
1062+
1063+
# wait until holddown
1064+
time.sleep(ignore_errors_period + 1)
1065+
1066+
# try again
1067+
kb.source = source_good
1068+
res = kb.do_remote()
1069+
assert res == True

tests/test_04_key_jar.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -746,6 +746,12 @@ def test_aud(self):
746746
keys = self.bob_keyjar.get_jwt_verify_keys(_jwt.jwt, no_kid_issuer=no_kid_issuer)
747747
assert len(keys) == 1
748748

749+
def test_inactive_verify_key(self):
750+
_jwt = factory(self.sjwt_b)
751+
self.alice_keyjar.return_issuer("Bob")[0].mark_all_as_inactive()
752+
keys = self.alice_keyjar.get_jwt_verify_keys(_jwt.jwt)
753+
assert len(keys) == 0
754+
749755

750756
def test_copy():
751757
kj = KeyJar()

tox.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ envlist = py{36,37,38},quality
44
[testenv]
55
passenv = CI TRAVIS TRAVIS_*
66
commands =
7-
py.test --cov=cryptojwt --isort --black {posargs}
7+
pytest -vvv -ra --cov=cryptojwt --isort --black {posargs}
88
codecov
99
extras = testing
1010
deps =

0 commit comments

Comments
 (0)