From 37af7047d81161334df98fbcb824a3429eed8318 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Padilla?= Date: Fri, 27 Dec 2019 10:21:32 -0500 Subject: [PATCH 01/29] Introduce better experience for JWKs --- jwt/__init__.py | 8 +++- jwt/algorithms.py | 7 +++- jwt/api_jwk.py | 68 ++++++++++++++++++++++++++++++++ jwt/api_jws.py | 8 ++++ jwt/api_jwt.py | 12 +++++- jwt/exceptions.py | 12 ++++++ jwt/jwks_client.py | 50 +++++++++++++++++++++++ setup.py | 10 +++-- tests/test_api_jwk.py | 92 +++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 258 insertions(+), 9 deletions(-) create mode 100644 jwt/api_jwk.py create mode 100644 jwt/jwks_client.py create mode 100644 tests/test_api_jwk.py diff --git a/jwt/__init__.py b/jwt/__init__.py index e8e1f4796..29add4f09 100644 --- a/jwt/__init__.py +++ b/jwt/__init__.py @@ -10,10 +10,10 @@ __title__ = "pyjwt" -__version__ = "1.7.1" +__version__ = "2.0.0.dev" __author__ = "José Padilla" __license__ = "MIT" -__copyright__ = "Copyright 2015-2018 José Padilla" +__copyright__ = "Copyright 2015-2020 José Padilla" from .api_jws import PyJWS @@ -25,6 +25,7 @@ register_algorithm, unregister_algorithm, ) +from .jwks_client import PyJWKClient from .exceptions import ( DecodeError, ExpiredSignature, @@ -40,4 +41,7 @@ InvalidTokenError, MissingRequiredClaimError, PyJWTError, + PyJWKError, + PyJWKSetError, + PyJWKClientError, ) diff --git a/jwt/algorithms.py b/jwt/algorithms.py index 293a47078..6f704e65f 100644 --- a/jwt/algorithms.py +++ b/jwt/algorithms.py @@ -292,7 +292,12 @@ def to_jwk(key_obj): @staticmethod def from_jwk(jwk): try: - obj = json.loads(jwk) + if isinstance(jwk, str): + obj = json.loads(jwk) + elif isinstance(jwk, dict): + obj = jwk + else: + raise ValueError except ValueError: raise InvalidKeyError("Key is not valid JSON") diff --git a/jwt/api_jwk.py b/jwt/api_jwk.py new file mode 100644 index 000000000..097ecae91 --- /dev/null +++ b/jwt/api_jwk.py @@ -0,0 +1,68 @@ +import json + +from .algorithms import get_default_algorithms +from .exceptions import PyJWKError, PyJWKSetError + + +class PyJWK(object): + def __init__(self, jwk_data, algorithm=None): + self._algorithms = get_default_algorithms() + self._jwk_data = jwk_data + + if not algorithm and isinstance(self._jwk_data, dict): + algorithm = self._jwk_data.get("alg", None) + + if not algorithm: + raise PyJWKError("Unable to find a algorithm for key: %s" % self._jwk_data) + + self.Algorithm = self._algorithms.get(algorithm) + + if not self.Algorithm: + raise PyJWKError("Unable to find a algorithm for key: %s" % self._jwk_data) + + self.key = self.Algorithm.from_jwk(self._jwk_data) + + @staticmethod + def from_dict(obj, algorithm=None): + return PyJWK(obj, algorithm) + + @staticmethod + def from_json(data, algorithm=None): + obj = json.loads(data) + return PyJWK.from_dict(obj, algorithm) + + @property + def key_type(self): + return self._jwk_data.get("kty", None) + + @property + def key_id(self): + return self._jwk_data.get("kid", None) + + @property + def public_key_use(self): + return self._jwk_data.get("use", None) + + +class PyJWKSet(object): + def __init__(self, keys): + self.keys = [] + + if not keys or not isinstance(keys, list): + raise PyJWKSetError("Invalid JWK Set value") + + if len(keys) == 0: + raise PyJWKSetError("The JWK Set did not contain any keys") + + for key in keys: + self.keys.append(PyJWK(key)) + + @staticmethod + def from_dict(obj): + keys = obj.get("keys", []) + return PyJWKSet(keys) + + @staticmethod + def from_json(data): + obj = json.loads(data) + return PyJWKSet.from_dict(obj) diff --git a/jwt/api_jws.py b/jwt/api_jws.py index 9504c9f69..850add0d7 100644 --- a/jwt/api_jws.py +++ b/jwt/api_jws.py @@ -135,6 +135,7 @@ def decode( verify=True, # type: bool algorithms=None, # type: List[str] options=None, # type: Dict + complete=False, # type: bool **kwargs ): @@ -163,6 +164,13 @@ def decode( payload, signing_input, header, signature, key, algorithms ) + if complete: + return { + "payload": payload, + "header": header, + "signature": signature + } + return payload def get_unverified_header(self, jwt): diff --git a/jwt/api_jwt.py b/jwt/api_jwt.py index 22bfc6a5d..de9db89d5 100644 --- a/jwt/api_jwt.py +++ b/jwt/api_jwt.py @@ -80,6 +80,7 @@ def decode( verify=True, # type: bool algorithms=None, # type: List[str] options=None, # type: Dict + complete=False, # type: bool **kwargs ): # type: (...) -> Dict[str, Any] @@ -100,11 +101,14 @@ def decode( options.setdefault("verify_signature", verify) decoded = super(PyJWT, self).decode( - jwt, key=key, algorithms=algorithms, options=options, **kwargs + jwt, key=key, algorithms=algorithms, options=options, complete=complete, **kwargs ) try: - payload = json.loads(decoded.decode("utf-8")) + if complete: + payload = json.loads(decoded["payload"].decode("utf-8")) + else: + payload = json.loads(decoded.decode("utf-8")) except ValueError as e: raise DecodeError("Invalid payload string: %s" % e) if not isinstance(payload, dict): @@ -114,6 +118,10 @@ def decode( merged_options = merge_dict(self.options, options) self._validate_claims(payload, merged_options, **kwargs) + if complete: + decoded["payload"] = payload + return decoded + return payload def _validate_claims( diff --git a/jwt/exceptions.py b/jwt/exceptions.py index cd2ca2a25..abe088beb 100644 --- a/jwt/exceptions.py +++ b/jwt/exceptions.py @@ -54,6 +54,18 @@ def __str__(self): return 'Token is missing the "%s" claim' % self.claim +class PyJWKError(PyJWTError): + pass + + +class PyJWKSetError(PyJWTError): + pass + + +class PyJWKClientError(PyJWTError): + pass + + # Compatibility aliases (deprecated) ExpiredSignature = ExpiredSignatureError InvalidAudience = InvalidAudienceError diff --git a/jwt/jwks_client.py b/jwt/jwks_client.py new file mode 100644 index 000000000..a09c6aa9f --- /dev/null +++ b/jwt/jwks_client.py @@ -0,0 +1,50 @@ +import requests + +from .api_jwk import PyJWKSet +from .api_jwt import decode as decode_token +from .exceptions import PyJWKClientError + + +class PyJWKClient: + def __init__(self, uri): + self.uri = uri + + def fetch_data(self): + r = requests.get(self.uri) + return r.json() + + def get_jwk_set(self): + data = self.fetch_data() + return PyJWKSet.from_dict(data) + + def get_signing_keys(self): + jwk_set = self.get_jwk_set() + signing_keys = list( + filter(lambda key: key.public_key_use == "sig" and key.key_id, jwk_set.keys,) + ) + + if len(signing_keys) == 0: + raise PyJWKClientError("The JWKS endpoint did not contain any signing keys") + + return signing_keys + + def get_signing_key(self, kid): + signing_keys = self.get_signing_keys() + signing_key = None + + for key in signing_keys: + if key.key_id == kid: + signing_key = key + break + + if not signing_key: + raise PyJWKClientError( + f'Unable to find a signing key that matches: "{kid}"' + ) + + return signing_key + + def get_signing_key_from_jwt(self, token): + unverified = decode_token(token, verify=False, complete=True) + header = unverified.get("header") + return self.get_signing_key(header.get("kid")) diff --git a/setup.py b/setup.py index 4d45f9f6a..6a90efca8 100755 --- a/setup.py +++ b/setup.py @@ -33,12 +33,16 @@ def get_version(package): sys.exit() EXTRAS_REQUIRE = { + "jwks-client": ["requests"], "tests": ["pytest>=4.0.1,<5.0.0", "pytest-cov>=2.6.0,<3.0.0"], "crypto": ["cryptography >= 1.4"], } EXTRAS_REQUIRE["dev"] = ( - EXTRAS_REQUIRE["tests"] + EXTRAS_REQUIRE["crypto"] + ["mypy", "pre-commit"] + EXTRAS_REQUIRE["tests"] + + EXTRAS_REQUIRE["crypto"] + + EXTRAS_REQUIRE["jwks-client"] + + ["mypy", "pre-commit"] ) setup( @@ -50,9 +54,7 @@ def get_version(package): license="MIT", keywords="jwt json web token security signing", url="https://github.com/jpadilla/pyjwt", - packages=find_packages( - exclude=["*.tests", "*.tests.*", "tests.*", "tests"] - ), + packages=find_packages(exclude=["*.tests", "*.tests.*", "tests.*", "tests"]), long_description=long_description, classifiers=[ "Development Status :: 5 - Production/Stable", diff --git a/tests/test_api_jwk.py b/tests/test_api_jwk.py new file mode 100644 index 000000000..a0b9d666a --- /dev/null +++ b/tests/test_api_jwk.py @@ -0,0 +1,92 @@ +import json + +from jwt.algorithms import RSAAlgorithm +from jwt.api_jwk import PyJWK, PyJWKSet + +from .utils import key_path + + +class TestPyJWK: + def test_should_load_key_from_jwk_data_dict(self): + algo = RSAAlgorithm(RSAAlgorithm.SHA256) + + with open(key_path("jwk_rsa_pub.json"), "r") as keyfile: + pub_key = algo.from_jwk(keyfile.read()) + + key_data_str = algo.to_jwk(pub_key) + key_data = json.loads(key_data_str) + + # TODO Should `to_jwk` set these? + key_data["alg"] = "RS256" + key_data["use"] = "sig" + key_data["kid"] = "keyid-abc123" + + jwk = PyJWK.from_dict(key_data) + + assert jwk.key_type == "RSA" + assert jwk.key_id == "keyid-abc123" + assert jwk.public_key_use == "sig" + + def test_should_load_key_from_jwk_data_json_string(self): + algo = RSAAlgorithm(RSAAlgorithm.SHA256) + + with open(key_path("jwk_rsa_pub.json"), "r") as keyfile: + pub_key = algo.from_jwk(keyfile.read()) + + key_data_str = algo.to_jwk(pub_key) + key_data = json.loads(key_data_str) + + # TODO Should `to_jwk` set these? + key_data["alg"] = "RS256" + key_data["use"] = "sig" + key_data["kid"] = "keyid-abc123" + + jwk = PyJWK.from_json(json.dumps(key_data)) + + assert jwk.key_type == "RSA" + assert jwk.key_id == "keyid-abc123" + assert jwk.public_key_use == "sig" + + +class TestPyJWKSet: + def test_should_load_keys_from_jwk_data_dict(self): + algo = RSAAlgorithm(RSAAlgorithm.SHA256) + + with open(key_path("jwk_rsa_pub.json"), "r") as keyfile: + pub_key = algo.from_jwk(keyfile.read()) + + key_data_str = algo.to_jwk(pub_key) + key_data = json.loads(key_data_str) + + # TODO Should `to_jwk` set these? + key_data["alg"] = "RS256" + key_data["use"] = "sig" + key_data["kid"] = "keyid-abc123" + + jwk_set = PyJWKSet.from_dict({"keys": [key_data]}) + jwk = jwk_set.keys[0] + + assert jwk.key_type == "RSA" + assert jwk.key_id == "keyid-abc123" + assert jwk.public_key_use == "sig" + + def test_should_load_keys_from_jwk_data_json_string(self): + algo = RSAAlgorithm(RSAAlgorithm.SHA256) + + with open(key_path("jwk_rsa_pub.json"), "r") as keyfile: + pub_key = algo.from_jwk(keyfile.read()) + + key_data_str = algo.to_jwk(pub_key) + key_data = json.loads(key_data_str) + + # TODO Should `to_jwk` set these? + key_data["alg"] = "RS256" + key_data["use"] = "sig" + key_data["kid"] = "keyid-abc123" + + jwk_set = PyJWKSet.from_json(json.dumps({"keys": [key_data]})) + jwk = jwk_set.keys[0] + + assert jwk.key_type == "RSA" + assert jwk.key_id == "keyid-abc123" + assert jwk.public_key_use == "sig" From 2275e41d8ed4acd1f5b0772c204583bc1ad541e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Padilla?= Date: Fri, 27 Dec 2019 10:29:24 -0500 Subject: [PATCH 02/29] Remove f-string --- jwt/jwks_client.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/jwt/jwks_client.py b/jwt/jwks_client.py index a09c6aa9f..887d5740c 100644 --- a/jwt/jwks_client.py +++ b/jwt/jwks_client.py @@ -20,7 +20,9 @@ def get_jwk_set(self): def get_signing_keys(self): jwk_set = self.get_jwk_set() signing_keys = list( - filter(lambda key: key.public_key_use == "sig" and key.key_id, jwk_set.keys,) + filter( + lambda key: key.public_key_use == "sig" and key.key_id, jwk_set.keys, + ) ) if len(signing_keys) == 0: @@ -39,7 +41,7 @@ def get_signing_key(self, kid): if not signing_key: raise PyJWKClientError( - f'Unable to find a signing key that matches: "{kid}"' + 'Unable to find a signing key that matches: "{kid}"'.format(kid) ) return signing_key From 1f4b8c6c3aed0d975232ce6e1b0f797d356a856b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Padilla?= Date: Fri, 27 Dec 2019 10:38:29 -0500 Subject: [PATCH 03/29] Guard against optional dependency --- jwt/jwks_client.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/jwt/jwks_client.py b/jwt/jwks_client.py index 887d5740c..e8c075168 100644 --- a/jwt/jwks_client.py +++ b/jwt/jwks_client.py @@ -1,12 +1,20 @@ -import requests - from .api_jwk import PyJWKSet from .api_jwt import decode as decode_token from .exceptions import PyJWKClientError +try: + import requests +except ImportError: + requests = None + class PyJWKClient: def __init__(self, uri): + if not requests: + raise PyJWKClientError( + "Missing dependencies for `PyJWKClient`. Run `pip install pyjwt[jwks-client]` to install dependencies." + ) + self.uri = uri def fetch_data(self): From e781e96e7851235bb53b12ae59c656008419f1b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Padilla?= Date: Fri, 27 Dec 2019 10:43:59 -0500 Subject: [PATCH 04/29] Update tests to guard against missing cryptography --- tests/test_api_jwk.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/test_api_jwk.py b/tests/test_api_jwk.py index a0b9d666a..6a7f78aa2 100644 --- a/tests/test_api_jwk.py +++ b/tests/test_api_jwk.py @@ -1,12 +1,22 @@ import json +import pytest -from jwt.algorithms import RSAAlgorithm from jwt.api_jwk import PyJWK, PyJWKSet from .utils import key_path +try: + from jwt.algorithms import RSAAlgorithm + + has_crypto = True +except ImportError: + has_crypto = False + class TestPyJWK: + @pytest.mark.skipif( + not has_crypto, reason="Scenario requires cryptography to not be installed" + ) def test_should_load_key_from_jwk_data_dict(self): algo = RSAAlgorithm(RSAAlgorithm.SHA256) @@ -27,6 +37,9 @@ def test_should_load_key_from_jwk_data_dict(self): assert jwk.key_id == "keyid-abc123" assert jwk.public_key_use == "sig" + @pytest.mark.skipif( + not has_crypto, reason="Scenario requires cryptography to not be installed" + ) def test_should_load_key_from_jwk_data_json_string(self): algo = RSAAlgorithm(RSAAlgorithm.SHA256) @@ -49,6 +62,9 @@ def test_should_load_key_from_jwk_data_json_string(self): class TestPyJWKSet: + @pytest.mark.skipif( + not has_crypto, reason="Scenario requires cryptography to not be installed" + ) def test_should_load_keys_from_jwk_data_dict(self): algo = RSAAlgorithm(RSAAlgorithm.SHA256) @@ -70,6 +86,9 @@ def test_should_load_keys_from_jwk_data_dict(self): assert jwk.key_id == "keyid-abc123" assert jwk.public_key_use == "sig" + @pytest.mark.skipif( + not has_crypto, reason="Scenario requires cryptography to not be installed" + ) def test_should_load_keys_from_jwk_data_json_string(self): algo = RSAAlgorithm(RSAAlgorithm.SHA256) From b5438a48b0d36557a3b9a351beb6c4ce5b1aadf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Padilla?= Date: Fri, 27 Dec 2019 11:09:38 -0500 Subject: [PATCH 05/29] Update travis + tox --- .travis.yml | 4 ++-- jwt/jwks_client.py | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index 5e2dd0c4b..451938721 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,9 +6,9 @@ matrix: - python: 3.6 env: TOXENV=py36-crypto,py36-nocrypto,py36-contrib_crypto - python: 3.7 - env: TOXENV=lint,typing,py37-crypto,py37-nocrypto,py37-contrib_crypto + env: TOXENV=py37-crypto,py37-nocrypto,py37-contrib_crypto - python: 3.8 - env: TOXENV=py38-crypto,py38-nocrypto,py38-contrib_crypto + env: TOXENV=lint,typing,py38-crypto,py38-nocrypto,py38-contrib_crypto install: - pip install -U pip - pip install -U tox coveralls diff --git a/jwt/jwks_client.py b/jwt/jwks_client.py index e8c075168..55cbda8cb 100644 --- a/jwt/jwks_client.py +++ b/jwt/jwks_client.py @@ -4,13 +4,15 @@ try: import requests + + has_requests = True except ImportError: - requests = None + has_requests = False class PyJWKClient: def __init__(self, uri): - if not requests: + if not has_requests: raise PyJWKClientError( "Missing dependencies for `PyJWKClient`. Run `pip install pyjwt[jwks-client]` to install dependencies." ) @@ -49,7 +51,7 @@ def get_signing_key(self, kid): if not signing_key: raise PyJWKClientError( - 'Unable to find a signing key that matches: "{kid}"'.format(kid) + 'Unable to find a signing key that matches: "{}"'.format(kid) ) return signing_key From 478e705614abb8fdc9959686626b8084671a5222 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Padilla?= Date: Fri, 27 Dec 2019 11:26:10 -0500 Subject: [PATCH 06/29] Update pre-commit and travis --- .pre-commit-config.yaml | 6 +++--- .travis.yml | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2617e46bc..6bc4e4ab2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,13 +3,13 @@ repos: rev: 19.3b0 hooks: - id: black - language_version: python3.7 + language_version: python3.8 - repo: https://gitlab.com/pycqa/flake8 rev: 3.7.8 hooks: - id: flake8 - language_version: python3.7 + language_version: python3.8 - repo: https://github.com/asottile/seed-isort-config rev: v1.9.3 @@ -21,7 +21,7 @@ repos: hooks: - id: isort additional_dependencies: [toml] - language_version: python3.7 + language_version: python3.8 - repo: https://github.com/pre-commit/pre-commit-hooks rev: v2.3.0 diff --git a/.travis.yml b/.travis.yml index 451938721..139e70003 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,8 @@ language: python matrix: include: + - python: 3.8 + env: TOXENV=lint,typing - python: 3.5 env: TOXENV=py35-crypto,py35-nocrypto,py35-contrib_crypto - python: 3.6 @@ -8,7 +10,7 @@ matrix: - python: 3.7 env: TOXENV=py37-crypto,py37-nocrypto,py37-contrib_crypto - python: 3.8 - env: TOXENV=lint,typing,py38-crypto,py38-nocrypto,py38-contrib_crypto + env: TOXENV=py38-crypto,py38-nocrypto,py38-contrib_crypto install: - pip install -U pip - pip install -U tox coveralls From 1c192882035cc22ad83687949e863bd74f46bf24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Padilla?= Date: Fri, 27 Dec 2019 11:37:24 -0500 Subject: [PATCH 07/29] Fix linting from pre-commit --- jwt/__init__.py | 6 +++--- jwt/api_jwk.py | 8 ++++++-- jwt/api_jws.py | 2 +- jwt/api_jwt.py | 7 ++++++- jwt/jwks_client.py | 7 +++++-- setup.py | 4 +++- tests/test_api_jwk.py | 13 +++++++++---- 7 files changed, 33 insertions(+), 14 deletions(-) diff --git a/jwt/__init__.py b/jwt/__init__.py index 29add4f09..cd56eff5d 100644 --- a/jwt/__init__.py +++ b/jwt/__init__.py @@ -25,7 +25,6 @@ register_algorithm, unregister_algorithm, ) -from .jwks_client import PyJWKClient from .exceptions import ( DecodeError, ExpiredSignature, @@ -40,8 +39,9 @@ InvalidSignatureError, InvalidTokenError, MissingRequiredClaimError, - PyJWTError, + PyJWKClientError, PyJWKError, PyJWKSetError, - PyJWKClientError, + PyJWTError, ) +from .jwks_client import PyJWKClient diff --git a/jwt/api_jwk.py b/jwt/api_jwk.py index 097ecae91..22771f1bb 100644 --- a/jwt/api_jwk.py +++ b/jwt/api_jwk.py @@ -13,12 +13,16 @@ def __init__(self, jwk_data, algorithm=None): algorithm = self._jwk_data.get("alg", None) if not algorithm: - raise PyJWKError("Unable to find a algorithm for key: %s" % self._jwk_data) + raise PyJWKError( + "Unable to find a algorithm for key: %s" % self._jwk_data + ) self.Algorithm = self._algorithms.get(algorithm) if not self.Algorithm: - raise PyJWKError("Unable to find a algorithm for key: %s" % self._jwk_data) + raise PyJWKError( + "Unable to find a algorithm for key: %s" % self._jwk_data + ) self.key = self.Algorithm.from_jwk(self._jwk_data) diff --git a/jwt/api_jws.py b/jwt/api_jws.py index 850add0d7..6a1569ef7 100644 --- a/jwt/api_jws.py +++ b/jwt/api_jws.py @@ -168,7 +168,7 @@ def decode( return { "payload": payload, "header": header, - "signature": signature + "signature": signature, } return payload diff --git a/jwt/api_jwt.py b/jwt/api_jwt.py index de9db89d5..122204ac2 100644 --- a/jwt/api_jwt.py +++ b/jwt/api_jwt.py @@ -101,7 +101,12 @@ def decode( options.setdefault("verify_signature", verify) decoded = super(PyJWT, self).decode( - jwt, key=key, algorithms=algorithms, options=options, complete=complete, **kwargs + jwt, + key=key, + algorithms=algorithms, + options=options, + complete=complete, + **kwargs ) try: diff --git a/jwt/jwks_client.py b/jwt/jwks_client.py index 55cbda8cb..fab993c87 100644 --- a/jwt/jwks_client.py +++ b/jwt/jwks_client.py @@ -31,12 +31,15 @@ def get_signing_keys(self): jwk_set = self.get_jwk_set() signing_keys = list( filter( - lambda key: key.public_key_use == "sig" and key.key_id, jwk_set.keys, + lambda key: key.public_key_use == "sig" and key.key_id, + jwk_set.keys, ) ) if len(signing_keys) == 0: - raise PyJWKClientError("The JWKS endpoint did not contain any signing keys") + raise PyJWKClientError( + "The JWKS endpoint did not contain any signing keys" + ) return signing_keys diff --git a/setup.py b/setup.py index 6a90efca8..44fbc927e 100755 --- a/setup.py +++ b/setup.py @@ -54,7 +54,9 @@ def get_version(package): license="MIT", keywords="jwt json web token security signing", url="https://github.com/jpadilla/pyjwt", - packages=find_packages(exclude=["*.tests", "*.tests.*", "tests.*", "tests"]), + packages=find_packages( + exclude=["*.tests", "*.tests.*", "tests.*", "tests"] + ), long_description=long_description, classifiers=[ "Development Status :: 5 - Production/Stable", diff --git a/tests/test_api_jwk.py b/tests/test_api_jwk.py index 6a7f78aa2..956133e9f 100644 --- a/tests/test_api_jwk.py +++ b/tests/test_api_jwk.py @@ -1,4 +1,5 @@ import json + import pytest from jwt.api_jwk import PyJWK, PyJWKSet @@ -15,7 +16,8 @@ class TestPyJWK: @pytest.mark.skipif( - not has_crypto, reason="Scenario requires cryptography to not be installed" + not has_crypto, + reason="Scenario requires cryptography to not be installed", ) def test_should_load_key_from_jwk_data_dict(self): algo = RSAAlgorithm(RSAAlgorithm.SHA256) @@ -38,7 +40,8 @@ def test_should_load_key_from_jwk_data_dict(self): assert jwk.public_key_use == "sig" @pytest.mark.skipif( - not has_crypto, reason="Scenario requires cryptography to not be installed" + not has_crypto, + reason="Scenario requires cryptography to not be installed", ) def test_should_load_key_from_jwk_data_json_string(self): algo = RSAAlgorithm(RSAAlgorithm.SHA256) @@ -63,7 +66,8 @@ def test_should_load_key_from_jwk_data_json_string(self): class TestPyJWKSet: @pytest.mark.skipif( - not has_crypto, reason="Scenario requires cryptography to not be installed" + not has_crypto, + reason="Scenario requires cryptography to not be installed", ) def test_should_load_keys_from_jwk_data_dict(self): algo = RSAAlgorithm(RSAAlgorithm.SHA256) @@ -87,7 +91,8 @@ def test_should_load_keys_from_jwk_data_dict(self): assert jwk.public_key_use == "sig" @pytest.mark.skipif( - not has_crypto, reason="Scenario requires cryptography to not be installed" + not has_crypto, + reason="Scenario requires cryptography to not be installed", ) def test_should_load_keys_from_jwk_data_json_string(self): algo = RSAAlgorithm(RSAAlgorithm.SHA256) From 20add39e445d71dd790c47045b257d3e2ff6232a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Padilla?= Date: Fri, 27 Dec 2019 12:29:29 -0500 Subject: [PATCH 08/29] Remove 3.8 from PyCrypto tests --- .travis.yml | 2 +- tox.ini | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 139e70003..9e4770f3e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,7 +10,7 @@ matrix: - python: 3.7 env: TOXENV=py37-crypto,py37-nocrypto,py37-contrib_crypto - python: 3.8 - env: TOXENV=py38-crypto,py38-nocrypto,py38-contrib_crypto + env: TOXENV=py38-crypto,py38-nocrypto install: - pip install -U pip - pip install -U tox coveralls diff --git a/tox.ini b/tox.ini index c9b1d9271..6d17162db 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,7 @@ envlist = lint typing py{35,36,37,38}-crypto - py{35,36,37,38}-contrib_crypto + py{35,36,37}-contrib_crypto py{35,36,37,38}-nocrypto From c2291b1d65eee3951b7590929d887972ed788cce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Padilla?= Date: Sun, 5 Jan 2020 21:13:48 -0500 Subject: [PATCH 09/29] Implement support with PyCryptodome --- jwt/contrib/algorithms/pycryptodome.py | 90 ++++++++++++ pyproject.toml | 2 +- setup.py | 6 +- tests/contrib/test_algorithms.py | 194 +++++++++++++++++++++++++ tests/keys/testkey_ec | 12 +- tests/keys/testkey_ec.pub | 6 +- tests/keys/testkey_ec_ssh.pub | 2 +- tests/test_api_jws.py | 9 +- tests/test_api_jwt.py | 13 +- tox.ini | 7 +- 10 files changed, 310 insertions(+), 31 deletions(-) create mode 100644 jwt/contrib/algorithms/pycryptodome.py diff --git a/jwt/contrib/algorithms/pycryptodome.py b/jwt/contrib/algorithms/pycryptodome.py new file mode 100644 index 000000000..ba5d53884 --- /dev/null +++ b/jwt/contrib/algorithms/pycryptodome.py @@ -0,0 +1,90 @@ +import Cryptodome.Hash.SHA256 +import Cryptodome.Hash.SHA384 +import Cryptodome.Hash.SHA512 +from Cryptodome.PublicKey import ECC, RSA +from Cryptodome.Signature import DSS, PKCS1_v1_5 + +from jwt.algorithms import Algorithm +from jwt.compat import string_types, text_type + + +class RSAAlgorithm(Algorithm): + """ + Performs signing and verification operations using + RSASSA-PKCS-v1_5 and the specified hash function. + + This class requires PyCryptodome package to be installed. + """ + + SHA256 = Cryptodome.Hash.SHA256 + SHA384 = Cryptodome.Hash.SHA384 + SHA512 = Cryptodome.Hash.SHA512 + + def __init__(self, hash_alg): + self.hash_alg = hash_alg + + def prepare_key(self, key): + + if isinstance(key, RSA.RsaKey): + return key + + if isinstance(key, string_types): + if isinstance(key, text_type): + key = key.encode("utf-8") + + key = RSA.importKey(key) + else: + raise TypeError("Expecting a PEM- or RSA-formatted key.") + + return key + + def sign(self, msg, key): + return PKCS1_v1_5.new(key).sign(self.hash_alg.new(msg)) + + def verify(self, msg, key, sig): + return PKCS1_v1_5.new(key).verify(self.hash_alg.new(msg), sig) + + +class ECAlgorithm(Algorithm): + """ + Performs signing and verification operations using + ECDSA and the specified hash function + + This class requires the PyCryptodome package to be installed. + """ + + SHA256 = Cryptodome.Hash.SHA256 + SHA384 = Cryptodome.Hash.SHA384 + SHA512 = Cryptodome.Hash.SHA512 + + def __init__(self, hash_alg): + self.hash_alg = hash_alg + + def prepare_key(self, key): + + if isinstance(key, ECC.EccKey): + return key + + if isinstance(key, string_types): + if isinstance(key, text_type): + key = key.encode("utf-8") + key = ECC.import_key(key) + else: + raise TypeError("Expecting a PEM- or ECC-formatted key.") + + return key + + def sign(self, msg, key): + signer = DSS.new(key, "fips-186-3") + hash_obj = self.hash_alg.new(msg) + return signer.sign(hash_obj) + + def verify(self, msg, key, sig): + verifier = DSS.new(key, "fips-186-3") + hash_obj = self.hash_alg.new(msg) + + try: + verifier.verify(hash_obj, sig) + return True + except ValueError: + return False diff --git a/pyproject.toml b/pyproject.toml index 82c796955..4eec1c909 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,4 +11,4 @@ use_parentheses=true combine_as_imports=true known_first_party="jwt" -known_third_party=["Crypto", "ecdsa", "pytest", "setuptools", "sphinx_rtd_theme"] +known_third_party=["Crypto", "Cryptodome", "ecdsa", "pytest", "setuptools", "sphinx_rtd_theme"] diff --git a/setup.py b/setup.py index 44fbc927e..85e3093ee 100755 --- a/setup.py +++ b/setup.py @@ -35,13 +35,15 @@ def get_version(package): EXTRAS_REQUIRE = { "jwks-client": ["requests"], "tests": ["pytest>=4.0.1,<5.0.0", "pytest-cov>=2.6.0,<3.0.0"], - "crypto": ["cryptography >= 1.4"], + "cryptography": ["cryptography >= 1.4"], + "pycryptodome": ["pycryptodomex"], } EXTRAS_REQUIRE["dev"] = ( EXTRAS_REQUIRE["tests"] - + EXTRAS_REQUIRE["crypto"] + + EXTRAS_REQUIRE["cryptography"] + EXTRAS_REQUIRE["jwks-client"] + + EXTRAS_REQUIRE["pycryptodome"] + ["mypy", "pre-commit"] ) diff --git a/tests/contrib/test_algorithms.py b/tests/contrib/test_algorithms.py index 4a1550b13..17d8f9420 100644 --- a/tests/contrib/test_algorithms.py +++ b/tests/contrib/test_algorithms.py @@ -20,6 +20,15 @@ except ImportError: has_ecdsa = False +try: + # fmt: off + from jwt.contrib.algorithms.pycryptodome import RSAAlgorithm, ECAlgorithm # noqa: F811 + # fmt: on + + has_pycryptodome = True +except ImportError: + has_pycryptodome = False + @pytest.mark.skipif( not has_pycrypto, reason="Not supported without PyCrypto library" @@ -212,3 +221,188 @@ def test_ec_prepare_key_should_be_idempotent(self): jwt_pub_key_second = algo.prepare_key(jwt_pub_key_first) assert jwt_pub_key_first == jwt_pub_key_second + + +@pytest.mark.skipif( + not has_pycryptodome, reason="Not supported without PyCryptodome library" +) +class TestPyCryptodomeAlgorithms: + def test_rsa_should_parse_pem_public_key(self): + algo = RSAAlgorithm(RSAAlgorithm.SHA256) + + with open(key_path("testkey2_rsa.pub.pem"), "r") as pem_key: + algo.prepare_key(pem_key.read()) + + def test_rsa_should_accept_unicode_key(self): + algo = RSAAlgorithm(RSAAlgorithm.SHA256) + + with open(key_path("testkey_rsa"), "r") as rsa_key: + algo.prepare_key(force_unicode(rsa_key.read())) + + def test_rsa_should_reject_non_string_key(self): + algo = RSAAlgorithm(RSAAlgorithm.SHA256) + + with pytest.raises(TypeError): + algo.prepare_key(None) + + def test_rsa_sign_should_generate_correct_signature_value(self): + algo = RSAAlgorithm(RSAAlgorithm.SHA256) + + jwt_message = force_bytes("Hello World!") + + expected_sig = base64.b64decode( + force_bytes( + "yS6zk9DBkuGTtcBzLUzSpo9gGJxJFOGvUqN01iLhWHrzBQ9ZEz3+Ae38AXp" + "10RWwscp42ySC85Z6zoN67yGkLNWnfmCZSEv+xqELGEvBJvciOKsrhiObUl" + "2mveSc1oeO/2ujkGDkkkJ2epn0YliacVjZF5+/uDmImUfAAj8lzjnHlzYix" + "sn5jGz1H07jYYbi9diixN8IUhXeTafwFg02IcONhum29V40Wu6O5tAKWlJX" + "fHJnNUzAEUOXS0WahHVb57D30pcgIji9z923q90p5c7E2cU8V+E1qe8NdCA" + "APCDzZZ9zQ/dgcMVaBrGrgimrcLbPjueOKFgSO+SSjIElKA==" + ) + ) + + with open(key_path("testkey_rsa"), "r") as keyfile: + jwt_key = algo.prepare_key(keyfile.read()) + + with open(key_path("testkey_rsa.pub"), "r") as keyfile: + jwt_pub_key = algo.prepare_key(keyfile.read()) + + algo.sign(jwt_message, jwt_key) + result = algo.verify(jwt_message, jwt_pub_key, expected_sig) + assert result + + def test_rsa_verify_should_return_false_if_signature_invalid(self): + algo = RSAAlgorithm(RSAAlgorithm.SHA256) + + jwt_message = force_bytes("Hello World!") + + jwt_sig = base64.b64decode( + force_bytes( + "yS6zk9DBkuGTtcBzLUzSpo9gGJxJFOGvUqN01iLhWHrzBQ9ZEz3+Ae38AXp" + "10RWwscp42ySC85Z6zoN67yGkLNWnfmCZSEv+xqELGEvBJvciOKsrhiObUl" + "2mveSc1oeO/2ujkGDkkkJ2epn0YliacVjZF5+/uDmImUfAAj8lzjnHlzYix" + "sn5jGz1H07jYYbi9diixN8IUhXeTafwFg02IcONhum29V40Wu6O5tAKWlJX" + "fHJnNUzAEUOXS0WahHVb57D30pcgIji9z923q90p5c7E2cU8V+E1qe8NdCA" + "APCDzZZ9zQ/dgcMVaBrGrgimrcLbPjueOKFgSO+SSjIElKA==" + ) + ) + + jwt_sig += force_bytes("123") # Signature is now invalid + + with open(key_path("testkey_rsa.pub"), "r") as keyfile: + jwt_pub_key = algo.prepare_key(keyfile.read()) + + result = algo.verify(jwt_message, jwt_pub_key, jwt_sig) + assert not result + + def test_rsa_verify_should_return_true_if_signature_valid(self): + algo = RSAAlgorithm(RSAAlgorithm.SHA256) + + jwt_message = force_bytes("Hello World!") + + jwt_sig = base64.b64decode( + force_bytes( + "yS6zk9DBkuGTtcBzLUzSpo9gGJxJFOGvUqN01iLhWHrzBQ9ZEz3+Ae38AXp" + "10RWwscp42ySC85Z6zoN67yGkLNWnfmCZSEv+xqELGEvBJvciOKsrhiObUl" + "2mveSc1oeO/2ujkGDkkkJ2epn0YliacVjZF5+/uDmImUfAAj8lzjnHlzYix" + "sn5jGz1H07jYYbi9diixN8IUhXeTafwFg02IcONhum29V40Wu6O5tAKWlJX" + "fHJnNUzAEUOXS0WahHVb57D30pcgIji9z923q90p5c7E2cU8V+E1qe8NdCA" + "APCDzZZ9zQ/dgcMVaBrGrgimrcLbPjueOKFgSO+SSjIElKA==" + ) + ) + + with open(key_path("testkey_rsa.pub"), "r") as keyfile: + jwt_pub_key = algo.prepare_key(keyfile.read()) + + result = algo.verify(jwt_message, jwt_pub_key, jwt_sig) + assert result + + def test_rsa_prepare_key_should_be_idempotent(self): + algo = RSAAlgorithm(RSAAlgorithm.SHA256) + + with open(key_path("testkey_rsa.pub"), "r") as keyfile: + jwt_pub_key_first = algo.prepare_key(keyfile.read()) + jwt_pub_key_second = algo.prepare_key(jwt_pub_key_first) + + assert jwt_pub_key_first == jwt_pub_key_second + + def test_ec_should_reject_non_string_key(self): + algo = ECAlgorithm(ECAlgorithm.SHA256) + + with pytest.raises(TypeError): + algo.prepare_key(None) + + def test_ec_should_accept_unicode_key(self): + algo = ECAlgorithm(ECAlgorithm.SHA256) + + with open(key_path("testkey_ec"), "r") as ec_key: + algo.prepare_key(force_unicode(ec_key.read())) + + def test_ec_sign_should_generate_correct_signature_value(self): + algo = ECAlgorithm(ECAlgorithm.SHA256) + + jwt_message = force_bytes("Hello World!") + + expected_sig = base64.b64decode( + force_bytes( + "v163MdgPbEj2C/ZPRvPtLBtKaKitqZ4wg0Vs7mpoQb+i18flD" + "eKb19cZn7h1E3tuP1CgthJvvIBdRKQf0OXlZA==" + ) + ) + + with open(key_path("testkey_ec"), "r") as keyfile: + jwt_key = algo.prepare_key(keyfile.read()) + + with open(key_path("testkey_ec.pub"), "r") as keyfile: + jwt_pub_key = algo.prepare_key(keyfile.read()) + + algo.sign(jwt_message, jwt_key) + result = algo.verify(jwt_message, jwt_pub_key, expected_sig) + assert result + + def test_ec_verify_should_return_false_if_signature_invalid(self): + algo = ECAlgorithm(ECAlgorithm.SHA256) + + jwt_message = force_bytes("Hello World!") + + jwt_sig = base64.b64decode( + force_bytes( + "v163MdgPbEj2C/ZPRvPtLBtKaKitqZ4wg0Vs7mpoQb+i18flD" + "eKb19cZn7h1E3tuP1CgthJvvIBdRKQf0OXlZA==" + ) + ) + + jwt_sig += force_bytes("123") # Signature is now invalid + + with open(key_path("testkey_ec.pub"), "r") as keyfile: + jwt_pub_key = algo.prepare_key(keyfile.read()) + + result = algo.verify(jwt_message, jwt_pub_key, jwt_sig) + assert not result + + def test_ec_verify_should_return_true_if_signature_valid(self): + algo = ECAlgorithm(ECAlgorithm.SHA256) + + jwt_message = force_bytes("Hello World!") + + jwt_sig = base64.b64decode( + force_bytes( + "v163MdgPbEj2C/ZPRvPtLBtKaKitqZ4wg0Vs7mpoQb+i18flD" + "eKb19cZn7h1E3tuP1CgthJvvIBdRKQf0OXlZA==" + ) + ) + + with open(key_path("testkey_ec.pub"), "r") as keyfile: + jwt_pub_key = algo.prepare_key(keyfile.read()) + + result = algo.verify(jwt_message, jwt_pub_key, jwt_sig) + assert result + + def test_ec_prepare_key_should_be_idempotent(self): + algo = ECAlgorithm(ECAlgorithm.SHA256) + + with open(key_path("testkey_ec.pub"), "r") as keyfile: + jwt_pub_key_first = algo.prepare_key(keyfile.read()) + jwt_pub_key_second = algo.prepare_key(jwt_pub_key_first) + + assert jwt_pub_key_first == jwt_pub_key_second diff --git a/tests/keys/testkey_ec b/tests/keys/testkey_ec index fa93275ff..c7c0fb7c6 100644 --- a/tests/keys/testkey_ec +++ b/tests/keys/testkey_ec @@ -1,7 +1,5 @@ ------BEGIN EC PRIVATE KEY----- -MIHbAgEBBEG4xN/z6gk7bPkEzs1hHOsbs+Gi2lku8YH4LkS4E1q9U9jSOjvEcFNH -m/CQjKi1rtpAb0/WL3p/wXsc26e7zmAA5KAHBgUrgQQAI6GBiQOBhgAEAVnCcDxA -J0v5OJBYFIcTReydEkEIWRvpzYMvv5l8IUOT2SFJiHdWtU45DV4is7+g6bbQanbh -28/1dBLR/kH1stAeAYWeTJ08gxo3M9Q0KinXsXm4c6G24UiGY6WHeWlOPKPa16fz -pwJ62o3XaRrCdGzX+K7TCwahWCTeizrJQAe8UwUY ------END EC PRIVATE KEY----- +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg2nninfu2jMHDwAbn +9oERUhRADS6duQaJEadybLaa0YShRANCAAQfMBxRZKUYEdy5/fLdGI2tYj6kTr50 +PZPt8jOD23rAR7dhtNpG1ojqopmH0AH5wEXadgk8nLCT4cAPK59Qp9Ek +-----END PRIVATE KEY----- diff --git a/tests/keys/testkey_ec.pub b/tests/keys/testkey_ec.pub index 7cd226c72..fe75697d6 100644 --- a/tests/keys/testkey_ec.pub +++ b/tests/keys/testkey_ec.pub @@ -1,6 +1,4 @@ -----BEGIN PUBLIC KEY----- -MIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQBWcJwPEAnS/k4kFgUhxNF7J0SQQhZ -G+nNgy+/mXwhQ5PZIUmId1a1TjkNXiKzv6DpttBqduHbz/V0EtH+QfWy0B4BhZ5M -nTyDGjcz1DQqKdexebhzobbhSIZjpYd5aU48o9rXp/OnAnrajddpGsJ0bNf4rtML -BqFYJN6LOslAB7xTBRg= +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEHzAcUWSlGBHcuf3y3RiNrWI+pE6+ +dD2T7fIzg9t6wEe3YbTaRtaI6qKZh9AB+cBF2nYJPJywk+HADyufUKfRJA== -----END PUBLIC KEY----- diff --git a/tests/keys/testkey_ec_ssh.pub b/tests/keys/testkey_ec_ssh.pub index 4fa3a6bbf..4a6428e6a 100644 --- a/tests/keys/testkey_ec_ssh.pub +++ b/tests/keys/testkey_ec_ssh.pub @@ -1 +1 @@ -ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAFZwnA8QCdL+TiQWBSHE0XsnRJBCFkb6c2DL7+ZfCFDk9khSYh3VrVOOQ1eIrO/oOm20Gp24dvP9XQS0f5B9bLQHgGFnkydPIMaNzPUNCop17F5uHOhtuFIhmOlh3lpTjyj2ten86cCetqN12kawnRs1/iu0wsGoVgk3os6yUAHvFMFGA== +ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBB8wHFFkpRgR3Ln98t0Yja1iPqROvnQ9k+3yM4PbesBHt2G02kbWiOqimYfQAfnARdp2CTycsJPhwA8rn1Cn0SQ= diff --git a/tests/test_api_jws.py b/tests/test_api_jws.py index 5e1b2eb25..f325b6dfe 100644 --- a/tests/test_api_jws.py +++ b/tests/test_api_jws.py @@ -224,12 +224,9 @@ def test_decodes_valid_es384_jws(self, jws): with open("tests/keys/testkey_ec.pub", "r") as fp: example_pubkey = fp.read() example_jws = ( - b"eyJhbGciOiJFUzM4NCIsInR5cCI6IkpXVCJ9" - b".eyJoZWxsbyI6IndvcmxkIn0" - b".AGtlemKghaIaYh1yeeekFH9fRuNY7hCaw5hUgZ5aG1N" - b"2F8FIbiKLaZKr8SiFdTimXFVTEmxpBQ9sRmdsDsnrM-1" - b"HAG0_zxxu0JyINOFT2iqF3URYl9HZ8kZWMeZAtXmn6Cw" - b"PXRJD2f7N-f7bJ5JeL9VT5beI2XD3FlK3GgRvI-eE-2Ik" + b"eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9." + b"eyJoZWxsbyI6IndvcmxkIn0.TORyNQab_MoXM7DvNKaTwbrJr4UY" + b"d2SsX8hhlnWelQFmPFSf_JzC2EbLnar92t-bXsDovzxp25ExazrVHkfPkQ" ) decoded_payload = jws.decode(example_jws, example_pubkey) json_payload = json.loads(force_unicode(decoded_payload)) diff --git a/tests/test_api_jwt.py b/tests/test_api_jwt.py index e4065c69e..4cc5ca783 100644 --- a/tests/test_api_jwt.py +++ b/tests/test_api_jwt.py @@ -199,20 +199,17 @@ def test_encode_datetime(self, jwt): @pytest.mark.skipif( not has_crypto, reason="Can't run without cryptography library" ) - def test_decodes_valid_es384_jwt(self, jwt): + def test_decodes_valid_es256_jwt(self, jwt): example_payload = {"hello": "world"} with open("tests/keys/testkey_ec.pub", "r") as fp: example_pubkey = fp.read() example_jwt = ( - b"eyJhbGciOiJFUzM4NCIsInR5cCI6IkpXVCJ9" - b".eyJoZWxsbyI6IndvcmxkIn0" - b".AddMgkmRhzqptDYqlmy_f2dzM6O9YZmVo-txs_CeAJD" - b"NoD8LN7YiPeLmtIhkO5_VZeHHKvtQcGc4lsq-Y72c4dK" - b"pANr1f6HEYhjpBc03u_bv06PYMcr5N2-9k97-qf-JCSb" - b"zqW6R250Q7gNCX5R7NrCl7MTM4DTBZkGbUlqsFUleiGlj" + b"eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9." + b"eyJoZWxsbyI6IndvcmxkIn0.TORyNQab_MoXM7DvNKaTwbrJr4UY" + b"d2SsX8hhlnWelQFmPFSf_JzC2EbLnar92t-bXsDovzxp25ExazrVHkfPkQ" ) - decoded_payload = jwt.decode(example_jwt, example_pubkey) + decoded_payload = jwt.decode(example_jwt, example_pubkey) assert decoded_payload == example_payload # 'Control' RSA JWT created by another library. diff --git a/tox.ini b/tox.ini index 6d17162db..9a5cb9bf4 100644 --- a/tox.ini +++ b/tox.ini @@ -5,13 +5,16 @@ envlist = py{35,36,37,38}-crypto py{35,36,37}-contrib_crypto py{35,36,37,38}-nocrypto + py{35,36,37,38}-contrib_pycryptodome [testenv] -extras = tests +extras = + tests + crypto: cryptography + contrib_pycryptodome: pycryptodome commands = pytest deps = - crypto: cryptography contrib_crypto: pycrypto contrib_crypto: ecdsa From 4934c86ab440a5f07b5a1b286d21f2ce8e18311c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Padilla?= Date: Sun, 5 Jan 2020 21:21:04 -0500 Subject: [PATCH 10/29] Remove incorrect ES521 algorithm Closes #465 --- jwt/algorithms.py | 6 +----- tests/test_api_jws.py | 8 ++++---- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/jwt/algorithms.py b/jwt/algorithms.py index 6f704e65f..26410d37d 100644 --- a/jwt/algorithms.py +++ b/jwt/algorithms.py @@ -51,7 +51,6 @@ "RS512", "ES256", "ES384", - "ES521", "ES512", "PS256", "PS384", @@ -79,10 +78,7 @@ def get_default_algorithms(): "RS512": RSAAlgorithm(RSAAlgorithm.SHA512), "ES256": ECAlgorithm(ECAlgorithm.SHA256), "ES384": ECAlgorithm(ECAlgorithm.SHA384), - "ES521": ECAlgorithm(ECAlgorithm.SHA512), - "ES512": ECAlgorithm( - ECAlgorithm.SHA512 - ), # Backward compat for #219 fix + "ES512": ECAlgorithm(ECAlgorithm.SHA512), "PS256": RSAPSSAlgorithm(RSAPSSAlgorithm.SHA256), "PS384": RSAPSSAlgorithm(RSAPSSAlgorithm.SHA384), "PS512": RSAPSSAlgorithm(RSAPSSAlgorithm.SHA512), diff --git a/tests/test_api_jws.py b/tests/test_api_jws.py index f325b6dfe..55db6072d 100644 --- a/tests/test_api_jws.py +++ b/tests/test_api_jws.py @@ -654,7 +654,7 @@ def test_encode_decode_with_ecdsa_sha512(self, jws, payload): password=None, backend=default_backend(), ) - jws_message = jws.encode(payload, priv_eckey, algorithm="ES521") + jws_message = jws.encode(payload, priv_eckey, algorithm="ES512") with open("tests/keys/testkey_ec.pub", "r") as ec_pub_file: pub_eckey = load_pem_public_key( @@ -665,7 +665,7 @@ def test_encode_decode_with_ecdsa_sha512(self, jws, payload): # string-formatted key with open("tests/keys/testkey_ec", "r") as ec_priv_file: priv_eckey = ec_priv_file.read() - jws_message = jws.encode(payload, priv_eckey, algorithm="ES521") + jws_message = jws.encode(payload, priv_eckey, algorithm="ES512") with open("tests/keys/testkey_ec.pub", "r") as ec_pub_file: pub_eckey = ec_pub_file.read() @@ -678,11 +678,11 @@ def test_ecdsa_related_algorithms(self, jws): if has_crypto: assert "ES256" in jws_algorithms assert "ES384" in jws_algorithms - assert "ES521" in jws_algorithms + assert "ES512" in jws_algorithms else: assert "ES256" not in jws_algorithms assert "ES384" not in jws_algorithms - assert "ES521" not in jws_algorithms + assert "ES512" not in jws_algorithms def test_skip_check_signature(self, jws): token = ( From ebcbed4b59fc769c5714b90ff6941643010309d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Padilla?= Date: Sun, 5 Jan 2020 21:55:28 -0500 Subject: [PATCH 11/29] Add support for PS* algorithms with PyCryptodome --- jwt/contrib/algorithms/pycryptodome.py | 39 ++++++++++++++++++++++-- tests/contrib/test_algorithms.py | 41 +++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/jwt/contrib/algorithms/pycryptodome.py b/jwt/contrib/algorithms/pycryptodome.py index ba5d53884..84b1c88b6 100644 --- a/jwt/contrib/algorithms/pycryptodome.py +++ b/jwt/contrib/algorithms/pycryptodome.py @@ -2,7 +2,7 @@ import Cryptodome.Hash.SHA384 import Cryptodome.Hash.SHA512 from Cryptodome.PublicKey import ECC, RSA -from Cryptodome.Signature import DSS, PKCS1_v1_5 +from Cryptodome.Signature import DSS, PKCS1_v1_5, pss from jwt.algorithms import Algorithm from jwt.compat import string_types, text_type @@ -61,7 +61,6 @@ def __init__(self, hash_alg): self.hash_alg = hash_alg def prepare_key(self, key): - if isinstance(key, ECC.EccKey): return key @@ -88,3 +87,39 @@ def verify(self, msg, key, sig): return True except ValueError: return False + + +class RSAPSSAlgorithm(RSAAlgorithm): + """ + Performs a signature using RSASSA-PSS with MGF1 + + This class requires the PyCryptodome package to be installed. + """ + + def prepare_key(self, key): + if isinstance(key, ECC.EccKey): + return key + + if isinstance(key, string_types): + if isinstance(key, text_type): + key = key.encode("utf-8") + key = RSA.import_key(key) + else: + raise TypeError("Expecting a PEM- or RSA-formatted key.") + + return key + + def sign(self, msg, key): + signer = pss.new(key) + hash_obj = self.hash_alg.new(msg) + return signer.sign(hash_obj) + + def verify(self, msg, key, sig): + hash_obj = self.hash_alg.new(msg) + verifier = pss.new(key) + + try: + verifier.verify(hash_obj, sig) + return True + except (ValueError, TypeError): + return False diff --git a/tests/contrib/test_algorithms.py b/tests/contrib/test_algorithms.py index 17d8f9420..b565a1be6 100644 --- a/tests/contrib/test_algorithms.py +++ b/tests/contrib/test_algorithms.py @@ -22,7 +22,7 @@ try: # fmt: off - from jwt.contrib.algorithms.pycryptodome import RSAAlgorithm, ECAlgorithm # noqa: F811 + from jwt.contrib.algorithms.pycryptodome import RSAAlgorithm, ECAlgorithm, RSAPSSAlgorithm # noqa: F811 # fmt: on has_pycryptodome = True @@ -406,3 +406,42 @@ def test_ec_prepare_key_should_be_idempotent(self): jwt_pub_key_second = algo.prepare_key(jwt_pub_key_first) assert jwt_pub_key_first == jwt_pub_key_second + + def test_rsa_pss_sign_then_verify_should_return_true(self): + algo = RSAPSSAlgorithm(RSAPSSAlgorithm.SHA256) + + message = force_bytes("Hello World!") + + with open(key_path("testkey_rsa"), "r") as keyfile: + priv_key = algo.prepare_key(keyfile.read()) + sig = algo.sign(message, priv_key) + + with open(key_path("testkey_rsa.pub"), "r") as keyfile: + pub_key = algo.prepare_key(keyfile.read()) + + result = algo.verify(message, pub_key, sig) + assert result + + def test_rsa_pss_verify_should_return_false_if_signature_invalid(self): + algo = RSAPSSAlgorithm(RSAPSSAlgorithm.SHA256) + + jwt_message = force_bytes("Hello World!") + + jwt_sig = base64.b64decode( + force_bytes( + "ywKAUGRIDC//6X+tjvZA96yEtMqpOrSppCNfYI7NKyon3P7doud5v65oWNu" + "vQsz0fzPGfF7mQFGo9Cm9Vn0nljm4G6PtqZRbz5fXNQBH9k10gq34AtM02c" + "/cveqACQ8gF3zxWh6qr9jVqIpeMEaEBIkvqG954E0HT9s9ybHShgHX9mlWk" + "186/LopP4xe5c/hxOQjwhv6yDlTiwJFiqjNCvj0GyBKsc4iECLGIIO+4mC4" + "daOCWqbpZDuLb1imKpmm8Nsm56kAxijMLZnpCcnPgyb7CqG+B93W9GHglA5" + "drUeR1gRtO7vqbZMsCAQ4bpjXxwbYyjQlEVuMl73UL6sOWg==" + ) + ) + + jwt_sig += force_bytes("123") # Signature is now invalid + + with open(key_path("testkey_rsa.pub"), "r") as keyfile: + jwt_pub_key = algo.prepare_key(keyfile.read()) + + result = algo.verify(jwt_message, jwt_pub_key, jwt_sig) + assert not result From 2ed5f56430767c6ec1e23fa07ad5e32b834893f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Padilla?= Date: Tue, 24 Mar 2020 07:58:24 -0400 Subject: [PATCH 12/29] Remove support for legacy dependencies --- .travis.yml | 6 +- docs/installation.rst | 35 -- jwt/contrib/__init__.py | 0 jwt/contrib/algorithms/__init__.py | 0 jwt/contrib/algorithms/py_ecdsa.py | 69 ---- jwt/contrib/algorithms/pycrypto.py | 47 --- jwt/contrib/algorithms/pycryptodome.py | 125 ------- tests/contrib/__init__.py | 0 tests/contrib/test_algorithms.py | 447 ------------------------- tox.ini | 6 - 10 files changed, 3 insertions(+), 732 deletions(-) delete mode 100644 jwt/contrib/__init__.py delete mode 100644 jwt/contrib/algorithms/__init__.py delete mode 100644 jwt/contrib/algorithms/py_ecdsa.py delete mode 100644 jwt/contrib/algorithms/pycrypto.py delete mode 100644 jwt/contrib/algorithms/pycryptodome.py delete mode 100644 tests/contrib/__init__.py delete mode 100644 tests/contrib/test_algorithms.py diff --git a/.travis.yml b/.travis.yml index 9e4770f3e..c34de14c7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,11 +4,11 @@ matrix: - python: 3.8 env: TOXENV=lint,typing - python: 3.5 - env: TOXENV=py35-crypto,py35-nocrypto,py35-contrib_crypto + env: TOXENV=py35-crypto,py35-nocrypto - python: 3.6 - env: TOXENV=py36-crypto,py36-nocrypto,py36-contrib_crypto + env: TOXENV=py36-crypto,py36-nocrypto - python: 3.7 - env: TOXENV=py37-crypto,py37-nocrypto,py37-contrib_crypto + env: TOXENV=py37-crypto,py37-nocrypto - python: 3.8 env: TOXENV=py38-crypto,py38-nocrypto install: diff --git a/docs/installation.rst b/docs/installation.rst index e423cfb29..726e6dceb 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -23,39 +23,4 @@ The ``pyjwt[crypto]`` format is recommended in requirements files in projects using ``PyJWT``, as a separate ``cryptography`` requirement line may later be mistaken for an unused requirement and removed. -.. _legacy-deps: - -Legacy Dependencies -------------------- - -Some environments, most notably Google App Engine, do not allow the installation -of Python packages that require compilation of C extensions and therefore -cannot install ``cryptography``. If you can install ``cryptography``, you -should disregard this section. - -If you are deploying an application to one of these environments, you may -need to use the legacy implementations of the digital signature algorithms: - -.. code-block:: console - - $ pip install pycrypto ecdsa - -Once you have installed ``pycrypto`` and ``ecdcsa``, you can tell PyJWT to use -the legacy implementations with ``jwt.register_algorithm()``. The following -example code shows how to configure PyJWT to use the legacy implementations -for RSA with SHA256 and EC with SHA256 signatures. - -.. code-block:: python - - import jwt - from jwt.contrib.algorithms.pycrypto import RSAAlgorithm - from jwt.contrib.algorithms.py_ecdsa import ECAlgorithm - - jwt.unregister_algorithm('RS256') - jwt.unregister_algorithm('ES256') - - jwt.register_algorithm('RS256', RSAAlgorithm(RSAAlgorithm.SHA256)) - jwt.register_algorithm('ES256', ECAlgorithm(ECAlgorithm.SHA256)) - - .. _`cryptography`: https://cryptography.io diff --git a/jwt/contrib/__init__.py b/jwt/contrib/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/jwt/contrib/algorithms/__init__.py b/jwt/contrib/algorithms/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/jwt/contrib/algorithms/py_ecdsa.py b/jwt/contrib/algorithms/py_ecdsa.py deleted file mode 100644 index 5b878f543..000000000 --- a/jwt/contrib/algorithms/py_ecdsa.py +++ /dev/null @@ -1,69 +0,0 @@ -# Note: This file is named py_ecdsa.py because import behavior in Python 2 -# would cause ecdsa.py to squash the ecdsa library that it depends upon. - -import hashlib - -import ecdsa - -from jwt.algorithms import Algorithm -from jwt.compat import string_types, text_type - - -class ECAlgorithm(Algorithm): - """ - Performs signing and verification operations using - ECDSA and the specified hash function - - This class requires the ecdsa package to be installed. - - This is based off of the implementation in PyJWT 0.3.2 - """ - - SHA256 = hashlib.sha256 - SHA384 = hashlib.sha384 - SHA512 = hashlib.sha512 - - def __init__(self, hash_alg): - self.hash_alg = hash_alg - - def prepare_key(self, key): - - if isinstance(key, ecdsa.SigningKey) or isinstance( - key, ecdsa.VerifyingKey - ): - return key - - if isinstance(key, string_types): - if isinstance(key, text_type): - key = key.encode("utf-8") - - # Attempt to load key. We don't know if it's - # a Signing Key or a Verifying Key, so we try - # the Verifying Key first. - try: - key = ecdsa.VerifyingKey.from_pem(key) - except ecdsa.der.UnexpectedDER: - key = ecdsa.SigningKey.from_pem(key) - - else: - raise TypeError("Expecting a PEM-formatted key.") - - return key - - def sign(self, msg, key): - return key.sign( - msg, hashfunc=self.hash_alg, sigencode=ecdsa.util.sigencode_string - ) - - def verify(self, msg, key, sig): - try: - return key.verify( - sig, - msg, - hashfunc=self.hash_alg, - sigdecode=ecdsa.util.sigdecode_string, - ) - # ecdsa <= 0.13.2 raises AssertionError on too long signatures, - # ecdsa >= 0.13.3 raises BadSignatureError for verification errors. - except (AssertionError, ecdsa.BadSignatureError): - return False diff --git a/jwt/contrib/algorithms/pycrypto.py b/jwt/contrib/algorithms/pycrypto.py deleted file mode 100644 index d58e907d7..000000000 --- a/jwt/contrib/algorithms/pycrypto.py +++ /dev/null @@ -1,47 +0,0 @@ -import Crypto.Hash.SHA256 -import Crypto.Hash.SHA384 -import Crypto.Hash.SHA512 -from Crypto.PublicKey import RSA -from Crypto.Signature import PKCS1_v1_5 - -from jwt.algorithms import Algorithm -from jwt.compat import string_types, text_type - - -class RSAAlgorithm(Algorithm): - """ - Performs signing and verification operations using - RSASSA-PKCS-v1_5 and the specified hash function. - - This class requires PyCrypto package to be installed. - - This is based off of the implementation in PyJWT 0.3.2 - """ - - SHA256 = Crypto.Hash.SHA256 - SHA384 = Crypto.Hash.SHA384 - SHA512 = Crypto.Hash.SHA512 - - def __init__(self, hash_alg): - self.hash_alg = hash_alg - - def prepare_key(self, key): - - if isinstance(key, RSA._RSAobj): - return key - - if isinstance(key, string_types): - if isinstance(key, text_type): - key = key.encode("utf-8") - - key = RSA.importKey(key) - else: - raise TypeError("Expecting a PEM- or RSA-formatted key.") - - return key - - def sign(self, msg, key): - return PKCS1_v1_5.new(key).sign(self.hash_alg.new(msg)) - - def verify(self, msg, key, sig): - return PKCS1_v1_5.new(key).verify(self.hash_alg.new(msg), sig) diff --git a/jwt/contrib/algorithms/pycryptodome.py b/jwt/contrib/algorithms/pycryptodome.py deleted file mode 100644 index 84b1c88b6..000000000 --- a/jwt/contrib/algorithms/pycryptodome.py +++ /dev/null @@ -1,125 +0,0 @@ -import Cryptodome.Hash.SHA256 -import Cryptodome.Hash.SHA384 -import Cryptodome.Hash.SHA512 -from Cryptodome.PublicKey import ECC, RSA -from Cryptodome.Signature import DSS, PKCS1_v1_5, pss - -from jwt.algorithms import Algorithm -from jwt.compat import string_types, text_type - - -class RSAAlgorithm(Algorithm): - """ - Performs signing and verification operations using - RSASSA-PKCS-v1_5 and the specified hash function. - - This class requires PyCryptodome package to be installed. - """ - - SHA256 = Cryptodome.Hash.SHA256 - SHA384 = Cryptodome.Hash.SHA384 - SHA512 = Cryptodome.Hash.SHA512 - - def __init__(self, hash_alg): - self.hash_alg = hash_alg - - def prepare_key(self, key): - - if isinstance(key, RSA.RsaKey): - return key - - if isinstance(key, string_types): - if isinstance(key, text_type): - key = key.encode("utf-8") - - key = RSA.importKey(key) - else: - raise TypeError("Expecting a PEM- or RSA-formatted key.") - - return key - - def sign(self, msg, key): - return PKCS1_v1_5.new(key).sign(self.hash_alg.new(msg)) - - def verify(self, msg, key, sig): - return PKCS1_v1_5.new(key).verify(self.hash_alg.new(msg), sig) - - -class ECAlgorithm(Algorithm): - """ - Performs signing and verification operations using - ECDSA and the specified hash function - - This class requires the PyCryptodome package to be installed. - """ - - SHA256 = Cryptodome.Hash.SHA256 - SHA384 = Cryptodome.Hash.SHA384 - SHA512 = Cryptodome.Hash.SHA512 - - def __init__(self, hash_alg): - self.hash_alg = hash_alg - - def prepare_key(self, key): - if isinstance(key, ECC.EccKey): - return key - - if isinstance(key, string_types): - if isinstance(key, text_type): - key = key.encode("utf-8") - key = ECC.import_key(key) - else: - raise TypeError("Expecting a PEM- or ECC-formatted key.") - - return key - - def sign(self, msg, key): - signer = DSS.new(key, "fips-186-3") - hash_obj = self.hash_alg.new(msg) - return signer.sign(hash_obj) - - def verify(self, msg, key, sig): - verifier = DSS.new(key, "fips-186-3") - hash_obj = self.hash_alg.new(msg) - - try: - verifier.verify(hash_obj, sig) - return True - except ValueError: - return False - - -class RSAPSSAlgorithm(RSAAlgorithm): - """ - Performs a signature using RSASSA-PSS with MGF1 - - This class requires the PyCryptodome package to be installed. - """ - - def prepare_key(self, key): - if isinstance(key, ECC.EccKey): - return key - - if isinstance(key, string_types): - if isinstance(key, text_type): - key = key.encode("utf-8") - key = RSA.import_key(key) - else: - raise TypeError("Expecting a PEM- or RSA-formatted key.") - - return key - - def sign(self, msg, key): - signer = pss.new(key) - hash_obj = self.hash_alg.new(msg) - return signer.sign(hash_obj) - - def verify(self, msg, key, sig): - hash_obj = self.hash_alg.new(msg) - verifier = pss.new(key) - - try: - verifier.verify(hash_obj, sig) - return True - except (ValueError, TypeError): - return False diff --git a/tests/contrib/__init__.py b/tests/contrib/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/contrib/test_algorithms.py b/tests/contrib/test_algorithms.py deleted file mode 100644 index b565a1be6..000000000 --- a/tests/contrib/test_algorithms.py +++ /dev/null @@ -1,447 +0,0 @@ -import base64 - -import pytest - -from jwt.utils import force_bytes, force_unicode - -from ..utils import key_path - -try: - from jwt.contrib.algorithms.pycrypto import RSAAlgorithm - - has_pycrypto = True -except ImportError: - has_pycrypto = False - -try: - from jwt.contrib.algorithms.py_ecdsa import ECAlgorithm - - has_ecdsa = True -except ImportError: - has_ecdsa = False - -try: - # fmt: off - from jwt.contrib.algorithms.pycryptodome import RSAAlgorithm, ECAlgorithm, RSAPSSAlgorithm # noqa: F811 - # fmt: on - - has_pycryptodome = True -except ImportError: - has_pycryptodome = False - - -@pytest.mark.skipif( - not has_pycrypto, reason="Not supported without PyCrypto library" -) -class TestPycryptoAlgorithms: - def test_rsa_should_parse_pem_public_key(self): - algo = RSAAlgorithm(RSAAlgorithm.SHA256) - - with open(key_path("testkey2_rsa.pub.pem"), "r") as pem_key: - algo.prepare_key(pem_key.read()) - - def test_rsa_should_accept_unicode_key(self): - algo = RSAAlgorithm(RSAAlgorithm.SHA256) - - with open(key_path("testkey_rsa"), "r") as rsa_key: - algo.prepare_key(force_unicode(rsa_key.read())) - - def test_rsa_should_reject_non_string_key(self): - algo = RSAAlgorithm(RSAAlgorithm.SHA256) - - with pytest.raises(TypeError): - algo.prepare_key(None) - - def test_rsa_sign_should_generate_correct_signature_value(self): - algo = RSAAlgorithm(RSAAlgorithm.SHA256) - - jwt_message = force_bytes("Hello World!") - - expected_sig = base64.b64decode( - force_bytes( - "yS6zk9DBkuGTtcBzLUzSpo9gGJxJFOGvUqN01iLhWHrzBQ9ZEz3+Ae38AXp" - "10RWwscp42ySC85Z6zoN67yGkLNWnfmCZSEv+xqELGEvBJvciOKsrhiObUl" - "2mveSc1oeO/2ujkGDkkkJ2epn0YliacVjZF5+/uDmImUfAAj8lzjnHlzYix" - "sn5jGz1H07jYYbi9diixN8IUhXeTafwFg02IcONhum29V40Wu6O5tAKWlJX" - "fHJnNUzAEUOXS0WahHVb57D30pcgIji9z923q90p5c7E2cU8V+E1qe8NdCA" - "APCDzZZ9zQ/dgcMVaBrGrgimrcLbPjueOKFgSO+SSjIElKA==" - ) - ) - - with open(key_path("testkey_rsa"), "r") as keyfile: - jwt_key = algo.prepare_key(keyfile.read()) - - with open(key_path("testkey_rsa.pub"), "r") as keyfile: - jwt_pub_key = algo.prepare_key(keyfile.read()) - - algo.sign(jwt_message, jwt_key) - result = algo.verify(jwt_message, jwt_pub_key, expected_sig) - assert result - - def test_rsa_verify_should_return_false_if_signature_invalid(self): - algo = RSAAlgorithm(RSAAlgorithm.SHA256) - - jwt_message = force_bytes("Hello World!") - - jwt_sig = base64.b64decode( - force_bytes( - "yS6zk9DBkuGTtcBzLUzSpo9gGJxJFOGvUqN01iLhWHrzBQ9ZEz3+Ae38AXp" - "10RWwscp42ySC85Z6zoN67yGkLNWnfmCZSEv+xqELGEvBJvciOKsrhiObUl" - "2mveSc1oeO/2ujkGDkkkJ2epn0YliacVjZF5+/uDmImUfAAj8lzjnHlzYix" - "sn5jGz1H07jYYbi9diixN8IUhXeTafwFg02IcONhum29V40Wu6O5tAKWlJX" - "fHJnNUzAEUOXS0WahHVb57D30pcgIji9z923q90p5c7E2cU8V+E1qe8NdCA" - "APCDzZZ9zQ/dgcMVaBrGrgimrcLbPjueOKFgSO+SSjIElKA==" - ) - ) - - jwt_sig += force_bytes("123") # Signature is now invalid - - with open(key_path("testkey_rsa.pub"), "r") as keyfile: - jwt_pub_key = algo.prepare_key(keyfile.read()) - - result = algo.verify(jwt_message, jwt_pub_key, jwt_sig) - assert not result - - def test_rsa_verify_should_return_true_if_signature_valid(self): - algo = RSAAlgorithm(RSAAlgorithm.SHA256) - - jwt_message = force_bytes("Hello World!") - - jwt_sig = base64.b64decode( - force_bytes( - "yS6zk9DBkuGTtcBzLUzSpo9gGJxJFOGvUqN01iLhWHrzBQ9ZEz3+Ae38AXp" - "10RWwscp42ySC85Z6zoN67yGkLNWnfmCZSEv+xqELGEvBJvciOKsrhiObUl" - "2mveSc1oeO/2ujkGDkkkJ2epn0YliacVjZF5+/uDmImUfAAj8lzjnHlzYix" - "sn5jGz1H07jYYbi9diixN8IUhXeTafwFg02IcONhum29V40Wu6O5tAKWlJX" - "fHJnNUzAEUOXS0WahHVb57D30pcgIji9z923q90p5c7E2cU8V+E1qe8NdCA" - "APCDzZZ9zQ/dgcMVaBrGrgimrcLbPjueOKFgSO+SSjIElKA==" - ) - ) - - with open(key_path("testkey_rsa.pub"), "r") as keyfile: - jwt_pub_key = algo.prepare_key(keyfile.read()) - - result = algo.verify(jwt_message, jwt_pub_key, jwt_sig) - assert result - - def test_rsa_prepare_key_should_be_idempotent(self): - algo = RSAAlgorithm(RSAAlgorithm.SHA256) - - with open(key_path("testkey_rsa.pub"), "r") as keyfile: - jwt_pub_key_first = algo.prepare_key(keyfile.read()) - jwt_pub_key_second = algo.prepare_key(jwt_pub_key_first) - - assert jwt_pub_key_first == jwt_pub_key_second - - -@pytest.mark.skipif( - not has_ecdsa, reason="Not supported without ecdsa library" -) -class TestEcdsaAlgorithms: - def test_ec_should_reject_non_string_key(self): - algo = ECAlgorithm(ECAlgorithm.SHA256) - - with pytest.raises(TypeError): - algo.prepare_key(None) - - def test_ec_should_accept_unicode_key(self): - algo = ECAlgorithm(ECAlgorithm.SHA256) - - with open(key_path("testkey_ec"), "r") as ec_key: - algo.prepare_key(force_unicode(ec_key.read())) - - def test_ec_sign_should_generate_correct_signature_value(self): - algo = ECAlgorithm(ECAlgorithm.SHA256) - - jwt_message = force_bytes("Hello World!") - - expected_sig = base64.b64decode( - force_bytes( - "AC+m4Jf/xI3guAC6w0w37t5zRpSCF6F4udEz5LiMiTIjCS4vcVe6dDOxK+M" - "mvkF8PxJuvqxP2CO3TR3okDPCl/NjATTO1jE+qBZ966CRQSSzcCM+tzcHzw" - "LZS5kbvKu0Acd/K6Ol2/W3B1NeV5F/gjvZn/jOwaLgWEUYsg0o4XVrAg65" - ) - ) - - with open(key_path("testkey_ec"), "r") as keyfile: - jwt_key = algo.prepare_key(keyfile.read()) - - with open(key_path("testkey_ec.pub"), "r") as keyfile: - jwt_pub_key = algo.prepare_key(keyfile.read()) - - algo.sign(jwt_message, jwt_key) - result = algo.verify(jwt_message, jwt_pub_key, expected_sig) - assert result - - def test_ec_verify_should_return_false_if_signature_invalid(self): - algo = ECAlgorithm(ECAlgorithm.SHA256) - - jwt_message = force_bytes("Hello World!") - - jwt_sig = base64.b64decode( - force_bytes( - "AC+m4Jf/xI3guAC6w0w37t5zRpSCF6F4udEz5LiMiTIjCS4vcVe6dDOxK+M" - "mvkF8PxJuvqxP2CO3TR3okDPCl/NjATTO1jE+qBZ966CRQSSzcCM+tzcHzw" - "LZS5kbvKu0Acd/K6Ol2/W3B1NeV5F/gjvZn/jOwaLgWEUYsg0o4XVrAg65" - ) - ) - - jwt_sig += force_bytes("123") # Signature is now invalid - - with open(key_path("testkey_ec.pub"), "r") as keyfile: - jwt_pub_key = algo.prepare_key(keyfile.read()) - - result = algo.verify(jwt_message, jwt_pub_key, jwt_sig) - assert not result - - def test_ec_verify_should_return_true_if_signature_valid(self): - algo = ECAlgorithm(ECAlgorithm.SHA256) - - jwt_message = force_bytes("Hello World!") - - jwt_sig = base64.b64decode( - force_bytes( - "AC+m4Jf/xI3guAC6w0w37t5zRpSCF6F4udEz5LiMiTIjCS4vcVe6dDOxK+M" - "mvkF8PxJuvqxP2CO3TR3okDPCl/NjATTO1jE+qBZ966CRQSSzcCM+tzcHzw" - "LZS5kbvKu0Acd/K6Ol2/W3B1NeV5F/gjvZn/jOwaLgWEUYsg0o4XVrAg65" - ) - ) - - with open(key_path("testkey_ec.pub"), "r") as keyfile: - jwt_pub_key = algo.prepare_key(keyfile.read()) - - result = algo.verify(jwt_message, jwt_pub_key, jwt_sig) - assert result - - def test_ec_prepare_key_should_be_idempotent(self): - algo = ECAlgorithm(ECAlgorithm.SHA256) - - with open(key_path("testkey_ec.pub"), "r") as keyfile: - jwt_pub_key_first = algo.prepare_key(keyfile.read()) - jwt_pub_key_second = algo.prepare_key(jwt_pub_key_first) - - assert jwt_pub_key_first == jwt_pub_key_second - - -@pytest.mark.skipif( - not has_pycryptodome, reason="Not supported without PyCryptodome library" -) -class TestPyCryptodomeAlgorithms: - def test_rsa_should_parse_pem_public_key(self): - algo = RSAAlgorithm(RSAAlgorithm.SHA256) - - with open(key_path("testkey2_rsa.pub.pem"), "r") as pem_key: - algo.prepare_key(pem_key.read()) - - def test_rsa_should_accept_unicode_key(self): - algo = RSAAlgorithm(RSAAlgorithm.SHA256) - - with open(key_path("testkey_rsa"), "r") as rsa_key: - algo.prepare_key(force_unicode(rsa_key.read())) - - def test_rsa_should_reject_non_string_key(self): - algo = RSAAlgorithm(RSAAlgorithm.SHA256) - - with pytest.raises(TypeError): - algo.prepare_key(None) - - def test_rsa_sign_should_generate_correct_signature_value(self): - algo = RSAAlgorithm(RSAAlgorithm.SHA256) - - jwt_message = force_bytes("Hello World!") - - expected_sig = base64.b64decode( - force_bytes( - "yS6zk9DBkuGTtcBzLUzSpo9gGJxJFOGvUqN01iLhWHrzBQ9ZEz3+Ae38AXp" - "10RWwscp42ySC85Z6zoN67yGkLNWnfmCZSEv+xqELGEvBJvciOKsrhiObUl" - "2mveSc1oeO/2ujkGDkkkJ2epn0YliacVjZF5+/uDmImUfAAj8lzjnHlzYix" - "sn5jGz1H07jYYbi9diixN8IUhXeTafwFg02IcONhum29V40Wu6O5tAKWlJX" - "fHJnNUzAEUOXS0WahHVb57D30pcgIji9z923q90p5c7E2cU8V+E1qe8NdCA" - "APCDzZZ9zQ/dgcMVaBrGrgimrcLbPjueOKFgSO+SSjIElKA==" - ) - ) - - with open(key_path("testkey_rsa"), "r") as keyfile: - jwt_key = algo.prepare_key(keyfile.read()) - - with open(key_path("testkey_rsa.pub"), "r") as keyfile: - jwt_pub_key = algo.prepare_key(keyfile.read()) - - algo.sign(jwt_message, jwt_key) - result = algo.verify(jwt_message, jwt_pub_key, expected_sig) - assert result - - def test_rsa_verify_should_return_false_if_signature_invalid(self): - algo = RSAAlgorithm(RSAAlgorithm.SHA256) - - jwt_message = force_bytes("Hello World!") - - jwt_sig = base64.b64decode( - force_bytes( - "yS6zk9DBkuGTtcBzLUzSpo9gGJxJFOGvUqN01iLhWHrzBQ9ZEz3+Ae38AXp" - "10RWwscp42ySC85Z6zoN67yGkLNWnfmCZSEv+xqELGEvBJvciOKsrhiObUl" - "2mveSc1oeO/2ujkGDkkkJ2epn0YliacVjZF5+/uDmImUfAAj8lzjnHlzYix" - "sn5jGz1H07jYYbi9diixN8IUhXeTafwFg02IcONhum29V40Wu6O5tAKWlJX" - "fHJnNUzAEUOXS0WahHVb57D30pcgIji9z923q90p5c7E2cU8V+E1qe8NdCA" - "APCDzZZ9zQ/dgcMVaBrGrgimrcLbPjueOKFgSO+SSjIElKA==" - ) - ) - - jwt_sig += force_bytes("123") # Signature is now invalid - - with open(key_path("testkey_rsa.pub"), "r") as keyfile: - jwt_pub_key = algo.prepare_key(keyfile.read()) - - result = algo.verify(jwt_message, jwt_pub_key, jwt_sig) - assert not result - - def test_rsa_verify_should_return_true_if_signature_valid(self): - algo = RSAAlgorithm(RSAAlgorithm.SHA256) - - jwt_message = force_bytes("Hello World!") - - jwt_sig = base64.b64decode( - force_bytes( - "yS6zk9DBkuGTtcBzLUzSpo9gGJxJFOGvUqN01iLhWHrzBQ9ZEz3+Ae38AXp" - "10RWwscp42ySC85Z6zoN67yGkLNWnfmCZSEv+xqELGEvBJvciOKsrhiObUl" - "2mveSc1oeO/2ujkGDkkkJ2epn0YliacVjZF5+/uDmImUfAAj8lzjnHlzYix" - "sn5jGz1H07jYYbi9diixN8IUhXeTafwFg02IcONhum29V40Wu6O5tAKWlJX" - "fHJnNUzAEUOXS0WahHVb57D30pcgIji9z923q90p5c7E2cU8V+E1qe8NdCA" - "APCDzZZ9zQ/dgcMVaBrGrgimrcLbPjueOKFgSO+SSjIElKA==" - ) - ) - - with open(key_path("testkey_rsa.pub"), "r") as keyfile: - jwt_pub_key = algo.prepare_key(keyfile.read()) - - result = algo.verify(jwt_message, jwt_pub_key, jwt_sig) - assert result - - def test_rsa_prepare_key_should_be_idempotent(self): - algo = RSAAlgorithm(RSAAlgorithm.SHA256) - - with open(key_path("testkey_rsa.pub"), "r") as keyfile: - jwt_pub_key_first = algo.prepare_key(keyfile.read()) - jwt_pub_key_second = algo.prepare_key(jwt_pub_key_first) - - assert jwt_pub_key_first == jwt_pub_key_second - - def test_ec_should_reject_non_string_key(self): - algo = ECAlgorithm(ECAlgorithm.SHA256) - - with pytest.raises(TypeError): - algo.prepare_key(None) - - def test_ec_should_accept_unicode_key(self): - algo = ECAlgorithm(ECAlgorithm.SHA256) - - with open(key_path("testkey_ec"), "r") as ec_key: - algo.prepare_key(force_unicode(ec_key.read())) - - def test_ec_sign_should_generate_correct_signature_value(self): - algo = ECAlgorithm(ECAlgorithm.SHA256) - - jwt_message = force_bytes("Hello World!") - - expected_sig = base64.b64decode( - force_bytes( - "v163MdgPbEj2C/ZPRvPtLBtKaKitqZ4wg0Vs7mpoQb+i18flD" - "eKb19cZn7h1E3tuP1CgthJvvIBdRKQf0OXlZA==" - ) - ) - - with open(key_path("testkey_ec"), "r") as keyfile: - jwt_key = algo.prepare_key(keyfile.read()) - - with open(key_path("testkey_ec.pub"), "r") as keyfile: - jwt_pub_key = algo.prepare_key(keyfile.read()) - - algo.sign(jwt_message, jwt_key) - result = algo.verify(jwt_message, jwt_pub_key, expected_sig) - assert result - - def test_ec_verify_should_return_false_if_signature_invalid(self): - algo = ECAlgorithm(ECAlgorithm.SHA256) - - jwt_message = force_bytes("Hello World!") - - jwt_sig = base64.b64decode( - force_bytes( - "v163MdgPbEj2C/ZPRvPtLBtKaKitqZ4wg0Vs7mpoQb+i18flD" - "eKb19cZn7h1E3tuP1CgthJvvIBdRKQf0OXlZA==" - ) - ) - - jwt_sig += force_bytes("123") # Signature is now invalid - - with open(key_path("testkey_ec.pub"), "r") as keyfile: - jwt_pub_key = algo.prepare_key(keyfile.read()) - - result = algo.verify(jwt_message, jwt_pub_key, jwt_sig) - assert not result - - def test_ec_verify_should_return_true_if_signature_valid(self): - algo = ECAlgorithm(ECAlgorithm.SHA256) - - jwt_message = force_bytes("Hello World!") - - jwt_sig = base64.b64decode( - force_bytes( - "v163MdgPbEj2C/ZPRvPtLBtKaKitqZ4wg0Vs7mpoQb+i18flD" - "eKb19cZn7h1E3tuP1CgthJvvIBdRKQf0OXlZA==" - ) - ) - - with open(key_path("testkey_ec.pub"), "r") as keyfile: - jwt_pub_key = algo.prepare_key(keyfile.read()) - - result = algo.verify(jwt_message, jwt_pub_key, jwt_sig) - assert result - - def test_ec_prepare_key_should_be_idempotent(self): - algo = ECAlgorithm(ECAlgorithm.SHA256) - - with open(key_path("testkey_ec.pub"), "r") as keyfile: - jwt_pub_key_first = algo.prepare_key(keyfile.read()) - jwt_pub_key_second = algo.prepare_key(jwt_pub_key_first) - - assert jwt_pub_key_first == jwt_pub_key_second - - def test_rsa_pss_sign_then_verify_should_return_true(self): - algo = RSAPSSAlgorithm(RSAPSSAlgorithm.SHA256) - - message = force_bytes("Hello World!") - - with open(key_path("testkey_rsa"), "r") as keyfile: - priv_key = algo.prepare_key(keyfile.read()) - sig = algo.sign(message, priv_key) - - with open(key_path("testkey_rsa.pub"), "r") as keyfile: - pub_key = algo.prepare_key(keyfile.read()) - - result = algo.verify(message, pub_key, sig) - assert result - - def test_rsa_pss_verify_should_return_false_if_signature_invalid(self): - algo = RSAPSSAlgorithm(RSAPSSAlgorithm.SHA256) - - jwt_message = force_bytes("Hello World!") - - jwt_sig = base64.b64decode( - force_bytes( - "ywKAUGRIDC//6X+tjvZA96yEtMqpOrSppCNfYI7NKyon3P7doud5v65oWNu" - "vQsz0fzPGfF7mQFGo9Cm9Vn0nljm4G6PtqZRbz5fXNQBH9k10gq34AtM02c" - "/cveqACQ8gF3zxWh6qr9jVqIpeMEaEBIkvqG954E0HT9s9ybHShgHX9mlWk" - "186/LopP4xe5c/hxOQjwhv6yDlTiwJFiqjNCvj0GyBKsc4iECLGIIO+4mC4" - "daOCWqbpZDuLb1imKpmm8Nsm56kAxijMLZnpCcnPgyb7CqG+B93W9GHglA5" - "drUeR1gRtO7vqbZMsCAQ4bpjXxwbYyjQlEVuMl73UL6sOWg==" - ) - ) - - jwt_sig += force_bytes("123") # Signature is now invalid - - with open(key_path("testkey_rsa.pub"), "r") as keyfile: - jwt_pub_key = algo.prepare_key(keyfile.read()) - - result = algo.verify(jwt_message, jwt_pub_key, jwt_sig) - assert not result diff --git a/tox.ini b/tox.ini index 9a5cb9bf4..8d3358710 100644 --- a/tox.ini +++ b/tox.ini @@ -3,20 +3,14 @@ envlist = lint typing py{35,36,37,38}-crypto - py{35,36,37}-contrib_crypto py{35,36,37,38}-nocrypto - py{35,36,37,38}-contrib_pycryptodome [testenv] extras = tests crypto: cryptography - contrib_pycryptodome: pycryptodome commands = pytest -deps = - contrib_crypto: pycrypto - contrib_crypto: ecdsa [testenv:typing] From b51b6dc1782bab43b9d82a964581271ad953fc9f Mon Sep 17 00:00:00 2001 From: Evert Lammerts Date: Mon, 6 Nov 2017 22:11:09 +0100 Subject: [PATCH 13/29] ECAlgorithm.to_jwk --- jwt/algorithms.py | 63 ++++++++++++++++++++++ pyproject.toml | 2 +- tests/keys/__init__.py | 8 +-- tests/keys/jwk_ec_key.json | 9 ---- tests/keys/jwk_ec_key_P-256.json | 8 +++ tests/keys/jwk_ec_key_P-384.json | 8 +++ tests/keys/jwk_ec_key_P-521.json | 8 +++ tests/keys/jwk_ec_pub.json | 8 --- tests/keys/jwk_ec_pub_P-256.json | 7 +++ tests/keys/jwk_ec_pub_P-384.json | 7 +++ tests/keys/jwk_ec_pub_P-521.json | 7 +++ tests/test_algorithms.py | 92 +++++++++++++++++++++++++++++++- 12 files changed, 204 insertions(+), 23 deletions(-) delete mode 100644 tests/keys/jwk_ec_key.json create mode 100644 tests/keys/jwk_ec_key_P-256.json create mode 100644 tests/keys/jwk_ec_key_P-384.json create mode 100644 tests/keys/jwk_ec_key_P-521.json delete mode 100644 tests/keys/jwk_ec_pub.json create mode 100644 tests/keys/jwk_ec_pub_P-256.json create mode 100644 tests/keys/jwk_ec_pub_P-384.json create mode 100644 tests/keys/jwk_ec_pub_P-521.json diff --git a/jwt/algorithms.py b/jwt/algorithms.py index 26410d37d..26618839a 100644 --- a/jwt/algorithms.py +++ b/jwt/algorithms.py @@ -40,6 +40,8 @@ from cryptography.hazmat.backends import default_backend from cryptography.exceptions import InvalidSignature + from cryptography.utils import int_from_bytes + has_crypto = True except ImportError: has_crypto = False @@ -430,6 +432,67 @@ def verify(self, msg, key, sig): except InvalidSignature: return False + @staticmethod + def from_jwk(jwk): + + try: + obj = json.loads(jwk) + except ValueError: + raise InvalidKeyError("Key is not valid JSON") + + if obj.get("kty") != "EC": + raise InvalidKeyError("Not an Elliptic curve key") + + if "x" not in obj or "y" not in obj: + raise InvalidKeyError("Not an Elliptic curve key") + + x = base64url_decode(force_bytes(obj.get("x"))) + y = base64url_decode(force_bytes(obj.get("y"))) + + curve = obj.get("crv") + if curve == "P-256": + if len(x) == len(y) == 32: + curve_obj = ec.SECP256R1() + else: + raise InvalidKeyError( + "Coords should be 32 bytes for curve P-256" + ) + elif curve == "P-384": + if len(x) == len(y) == 48: + curve_obj = ec.SECP384R1() + else: + raise InvalidKeyError( + "Coords should be 48 bytes for curve P-384" + ) + elif curve == "P-521": + if len(x) == len(y) == 66: + curve_obj = ec.SECP521R1() + else: + raise InvalidKeyError( + "Coords should be 66 bytes for curve P-521" + ) + else: + raise InvalidKeyError("Invalid curve: {}".format(curve)) + + public_numbers = ec.EllipticCurvePublicNumbers( + x=int_from_bytes(x, "big"), + y=int_from_bytes(y, "big"), + curve=curve_obj, + ) + + if "d" not in obj: + return public_numbers.public_key(default_backend()) + + d = base64url_decode(force_bytes(obj.get("d"))) + if len(d) != len(x): + raise InvalidKeyError( + "D should be {} bytes for curve {}", len(x), curve + ) + + return ec.EllipticCurvePrivateNumbers( + int_from_bytes(d, "big"), public_numbers + ).private_key(default_backend()) + class RSAPSSAlgorithm(RSAAlgorithm): """ Performs a signature using RSASSA-PSS with MGF1 diff --git a/pyproject.toml b/pyproject.toml index 4eec1c909..1cf228133 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,4 +11,4 @@ use_parentheses=true combine_as_imports=true known_first_party="jwt" -known_third_party=["Crypto", "Cryptodome", "ecdsa", "pytest", "setuptools", "sphinx_rtd_theme"] +known_third_party=["pytest", "setuptools", "sphinx_rtd_theme"] diff --git a/tests/keys/__init__.py b/tests/keys/__init__.py index 6b61caa8b..2cf0b15d0 100644 --- a/tests/keys/__init__.py +++ b/tests/keys/__init__.py @@ -44,11 +44,13 @@ def load_ec_key(): return ec.EllipticCurvePrivateNumbers( private_value=decode_value(keyobj["d"]), - public_numbers=load_ec_pub_key().public_numbers(), + public_numbers=load_ec_pub_key_p_521().public_numbers(), ) - def load_ec_pub_key(): - with open(os.path.join(BASE_PATH, "jwk_ec_pub.json"), "r") as infile: + def load_ec_pub_key_p_521(): + with open( + os.path.join(BASE_PATH, "jwk_ec_pub_P-521.json"), "r" + ) as infile: keyobj = json.load(infile) return ec.EllipticCurvePublicNumbers( diff --git a/tests/keys/jwk_ec_key.json b/tests/keys/jwk_ec_key.json deleted file mode 100644 index a7fa999e3..000000000 --- a/tests/keys/jwk_ec_key.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "kty": "EC", - "kid": "bilbo.baggins@hobbiton.example", - "use": "sig", - "crv": "P-521", - "x": "AHKZLLOsCOzz5cY97ewNUajB957y-C-U88c3v13nmGZx6sYl_oJXu9A5RkTKqjqvjyekWF-7ytDyRXYgCF5cj0Kt", - "y": "AdymlHvOiLxXkEhayXQnNCvDX4h9htZaCJN34kfmC6pV5OhQHiraVySsUdaQkAgDPrwQrJmbnX9cwlGfP-HqHZR1", - "d": "AAhRON2r9cqXX1hg-RoI6R1tX5p2rUAYdmpHZoC1XNM56KtscrX6zbKipQrCW9CGZH3T4ubpnoTKLDYJ_fF3_rJt" -} diff --git a/tests/keys/jwk_ec_key_P-256.json b/tests/keys/jwk_ec_key_P-256.json new file mode 100644 index 000000000..7c67b24cd --- /dev/null +++ b/tests/keys/jwk_ec_key_P-256.json @@ -0,0 +1,8 @@ +{ + "kty": "EC", + "kid": "bilbo.baggins.256@hobbiton.example", + "crv": "P-256", + "x": "PTTjIY84aLtaZCxLTrG_d8I0G6YKCV7lg8M4xkKfwQ4=", + "y": "ank6KA34vv24HZLXlChVs85NEGlpg2sbqNmR_BcgyJU=", + "d": "9GJquUJf57a9sev-u8-PoYlIezIPqI_vGpIaiu4zyZk=" +} diff --git a/tests/keys/jwk_ec_key_P-384.json b/tests/keys/jwk_ec_key_P-384.json new file mode 100644 index 000000000..ff1a9b59f --- /dev/null +++ b/tests/keys/jwk_ec_key_P-384.json @@ -0,0 +1,8 @@ +{ + "kty": "EC", + "kid": "bilbo.baggins.384@hobbiton.example", + "crv": "P-384", + "x": "IDC-5s6FERlbC4Nc_4JhKW8sd51AhixtMdNUtPxhRFP323QY6cwWeIA3leyZhz-J", + "y": "eovmN9ocANS8IJxDAGSuC1FehTq5ZFLJU7XSPg36zHpv4H2byKGEcCBiwT4sFJsy", + "d": "xKPj5IXjiHpQpLOgyMGo6lg_DUp738SuXkiugCFMxbGNKTyTprYPfJz42wTOXbtd" +} diff --git a/tests/keys/jwk_ec_key_P-521.json b/tests/keys/jwk_ec_key_P-521.json new file mode 100644 index 000000000..28c54be17 --- /dev/null +++ b/tests/keys/jwk_ec_key_P-521.json @@ -0,0 +1,8 @@ +{ + "kty": "EC", + "kid": "bilbo.baggins.521@hobbiton.example", + "crv": "P-521", + "x": "AHKZLLOsCOzz5cY97ewNUajB957y-C-U88c3v13nmGZx6sYl_oJXu9A5RkTKqjqvjyekWF-7ytDyRXYgCF5cj0Kt", + "y": "AdymlHvOiLxXkEhayXQnNCvDX4h9htZaCJN34kfmC6pV5OhQHiraVySsUdaQkAgDPrwQrJmbnX9cwlGfP-HqHZR1", + "d": "AAhRON2r9cqXX1hg-RoI6R1tX5p2rUAYdmpHZoC1XNM56KtscrX6zbKipQrCW9CGZH3T4ubpnoTKLDYJ_fF3_rJt" +} diff --git a/tests/keys/jwk_ec_pub.json b/tests/keys/jwk_ec_pub.json deleted file mode 100644 index 5259ceb71..000000000 --- a/tests/keys/jwk_ec_pub.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "kty": "EC", - "kid": "bilbo.baggins@hobbiton.example", - "use": "sig", - "crv": "P-521", - "x": "AHKZLLOsCOzz5cY97ewNUajB957y-C-U88c3v13nmGZx6sYl_oJXu9A5RkTKqjqvjyekWF-7ytDyRXYgCF5cj0Kt", - "y": "AdymlHvOiLxXkEhayXQnNCvDX4h9htZaCJN34kfmC6pV5OhQHiraVySsUdaQkAgDPrwQrJmbnX9cwlGfP-HqHZR1" -} diff --git a/tests/keys/jwk_ec_pub_P-256.json b/tests/keys/jwk_ec_pub_P-256.json new file mode 100644 index 000000000..13db2b38c --- /dev/null +++ b/tests/keys/jwk_ec_pub_P-256.json @@ -0,0 +1,7 @@ +{ + "kty": "EC", + "kid": "bilbo.baggins.256@hobbiton.example", + "crv": "P-256", + "x": "PTTjIY84aLtaZCxLTrG_d8I0G6YKCV7lg8M4xkKfwQ4=", + "y": "ank6KA34vv24HZLXlChVs85NEGlpg2sbqNmR_BcgyJU=" +} diff --git a/tests/keys/jwk_ec_pub_P-384.json b/tests/keys/jwk_ec_pub_P-384.json new file mode 100644 index 000000000..0428a5129 --- /dev/null +++ b/tests/keys/jwk_ec_pub_P-384.json @@ -0,0 +1,7 @@ +{ + "kty": "EC", + "kid": "bilbo.baggins.384@hobbiton.example", + "crv": "P-384", + "x": "IDC-5s6FERlbC4Nc_4JhKW8sd51AhixtMdNUtPxhRFP323QY6cwWeIA3leyZhz-J", + "y": "eovmN9ocANS8IJxDAGSuC1FehTq5ZFLJU7XSPg36zHpv4H2byKGEcCBiwT4sFJsy" +} diff --git a/tests/keys/jwk_ec_pub_P-521.json b/tests/keys/jwk_ec_pub_P-521.json new file mode 100644 index 000000000..e624136e3 --- /dev/null +++ b/tests/keys/jwk_ec_pub_P-521.json @@ -0,0 +1,7 @@ +{ + "kty": "EC", + "kid": "bilbo.baggins.521@hobbiton.example", + "crv": "P-521", + "x": "AHKZLLOsCOzz5cY97ewNUajB957y-C-U88c3v13nmGZx6sYl_oJXu9A5RkTKqjqvjyekWF-7ytDyRXYgCF5cj0Kt", + "y": "AdymlHvOiLxXkEhayXQnNCvDX4h9htZaCJN34kfmC6pV5OhQHiraVySsUdaQkAgDPrwQrJmbnX9cwlGfP-HqHZR1" +} diff --git a/tests/test_algorithms.py b/tests/test_algorithms.py index 79d1b8e4a..adb1d2a7b 100644 --- a/tests/test_algorithms.py +++ b/tests/test_algorithms.py @@ -12,7 +12,7 @@ try: from jwt.algorithms import RSAAlgorithm, ECAlgorithm, RSAPSSAlgorithm - from .keys import load_rsa_pub_key, load_ec_pub_key + from .keys import load_rsa_pub_key, load_ec_pub_key_p_521 has_crypto = True except ImportError: @@ -190,6 +190,94 @@ def test_rsa_verify_should_return_false_if_signature_invalid(self): result = algo.verify(message, pub_key, sig) assert not result + @pytest.mark.skipif( + not has_crypto, reason="Not supported without cryptography library" + ) + def test_ec_jwk_public_and_private_keys_should_parse_and_verify(self): + tests = { + "P-256": ECAlgorithm.SHA256, + "P-384": ECAlgorithm.SHA384, + "P-521": ECAlgorithm.SHA512, + } + for (curve, hash) in tests.items(): + algo = ECAlgorithm(hash) + + with open( + key_path("jwk_ec_pub_{}.json".format(curve)), "r" + ) as keyfile: + pub_key = algo.from_jwk(keyfile.read()) + + with open( + key_path("jwk_ec_key_{}.json".format(curve)), "r" + ) as keyfile: + priv_key = algo.from_jwk(keyfile.read()) + + signature = algo.sign(force_bytes("Hello World!"), priv_key) + assert algo.verify(force_bytes("Hello World!"), pub_key, signature) + + @pytest.mark.skipif( + not has_crypto, reason="Not supported without cryptography library" + ) + def test_ec_jwk_fails_on_invalid_json(self): + algo = ECAlgorithm(ECAlgorithm.SHA512) + + valid_points = { + "P-256": { + "x": "PTTjIY84aLtaZCxLTrG_d8I0G6YKCV7lg8M4xkKfwQ4=", + "y": "ank6KA34vv24HZLXlChVs85NEGlpg2sbqNmR_BcgyJU=", + }, + "P-384": { + "x": "IDC-5s6FERlbC4Nc_4JhKW8sd51AhixtMdNUtPxhRFP323QY6cwWeIA3leyZhz-J", + "y": "eovmN9ocANS8IJxDAGSuC1FehTq5ZFLJU7XSPg36zHpv4H2byKGEcCBiwT4sFJsy", + }, + "P-521": { + "x": "AHKZLLOsCOzz5cY97ewNUajB957y-C-U88c3v13nmGZx6sYl_oJXu9A5RkTKqjqvjyekWF-7ytDyRXYgCF5cj0Kt", + "y": "AdymlHvOiLxXkEhayXQnNCvDX4h9htZaCJN34kfmC6pV5OhQHiraVySsUdaQkAgDPrwQrJmbnX9cwlGfP-HqHZR1", + }, + } + + # Invalid JSON + with pytest.raises(InvalidKeyError): + algo.from_jwk("") + + # Bad key type + with pytest.raises(InvalidKeyError): + algo.from_jwk('{"kty": "RSA"}') + + # Missing data + with pytest.raises(InvalidKeyError): + algo.from_jwk('{"kty": "EC"}') + with pytest.raises(InvalidKeyError): + algo.from_jwk('{"kty": "EC", "x": "1"}') + with pytest.raises(InvalidKeyError): + algo.from_jwk('{"kty": "EC", "y": "1"}') + + # Missing curve + with pytest.raises(InvalidKeyError): + algo.from_jwk('{"kty": "EC", "x": "dGVzdA==", "y": "dGVzdA=="}') + + # EC coordinates not equally long + with pytest.raises(InvalidKeyError): + algo.from_jwk( + '{"kty": "EC", "x": "dGVzdHRlc3Q=", "y": "dGVzdA=="}' + ) + + # EC coordinates length invalid + for curve in ("P-256", "P-384", "P-521"): + with pytest.raises(InvalidKeyError): + algo.from_jwk( + '{{"kty": "EC", "crv": "{}", "x": "dGVzdA==", ' + '"y": "dGVzdA=="}}'.format(curve) + ) + + # EC private key length invalid + for (curve, point) in valid_points.items(): + with pytest.raises(InvalidKeyError): + algo.from_jwk( + '{{"kty": "EC", "crv": "{}", "x": "{}", "y": "{}", ' + '"d": "dGVzdA=="}}'.format(curve, point["x"], point["y"]) + ) + @pytest.mark.skipif( not has_crypto, reason="Not supported without cryptography library" ) @@ -665,7 +753,7 @@ def test_ec_verify_should_return_true_for_test_vector(self): ) algo = ECAlgorithm(ECAlgorithm.SHA512) - key = algo.prepare_key(load_ec_pub_key()) + key = algo.prepare_key(load_ec_pub_key_p_521()) result = algo.verify(signing_input, key, signature) assert result From c8187c641b1b1fc8381afa9a75299f17721edfb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Padilla?= Date: Tue, 24 Mar 2020 08:30:05 -0400 Subject: [PATCH 14/29] Remove pycryptodome dependency --- setup.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/setup.py b/setup.py index 85e3093ee..99bbc0979 100755 --- a/setup.py +++ b/setup.py @@ -36,14 +36,12 @@ def get_version(package): "jwks-client": ["requests"], "tests": ["pytest>=4.0.1,<5.0.0", "pytest-cov>=2.6.0,<3.0.0"], "cryptography": ["cryptography >= 1.4"], - "pycryptodome": ["pycryptodomex"], } EXTRAS_REQUIRE["dev"] = ( EXTRAS_REQUIRE["tests"] + EXTRAS_REQUIRE["cryptography"] + EXTRAS_REQUIRE["jwks-client"] - + EXTRAS_REQUIRE["pycryptodome"] + ["mypy", "pre-commit"] ) From e490f9d8b8bc927a0fb337eed446a44858886242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Padilla?= Date: Sat, 28 Mar 2020 15:19:16 -0400 Subject: [PATCH 15/29] Remove CLI --- jwt/__main__.py | 189 ---------------------------------------------- tests/test_cli.py | 177 ------------------------------------------- 2 files changed, 366 deletions(-) delete mode 100644 jwt/__main__.py delete mode 100644 tests/test_cli.py diff --git a/jwt/__main__.py b/jwt/__main__.py deleted file mode 100644 index a6b9f3963..000000000 --- a/jwt/__main__.py +++ /dev/null @@ -1,189 +0,0 @@ -#!/usr/bin/env python3 - -import argparse -import json -import sys -import time - -from . import DecodeError, __version__, decode, encode - - -def encode_payload(args): - # Try to encode - if args.key is None: - raise ValueError( - "Key is required when encoding. See --help for usage." - ) - - # Build payload object to encode - payload = {} - - for arg in args.payload: - k, v = arg.split("=", 1) - - # exp +offset special case? - if k == "exp" and v[0] == "+" and len(v) > 1: - v = str(int(time.time() + int(v[1:]))) - - # Cast to integer? - if v.isdigit(): - v = int(v) - else: - # Cast to float? - try: - v = float(v) - except ValueError: - pass - - # Cast to true, false, or null? - constants = {"true": True, "false": False, "null": None} - - if v in constants: - v = constants[v] - - payload[k] = v - - # Build header object to encode - header = {} - if args.header: - try: - header = json.loads(args.header) - except Exception as e: - raise ValueError( - "Error loading header: %s. See --help for usage." % e - ) - - token = encode( - payload, key=args.key, algorithm=args.algorithm, headers=header - ) - - return token.decode("utf-8") - - -def decode_payload(args): - try: - if args.token: - token = args.token - else: - if sys.stdin.isatty(): - token = sys.stdin.readline().strip() - else: - raise IOError("Cannot read from stdin: terminal not a TTY") - - token = token.encode("utf-8") - data = decode(token, key=args.key, verify=args.verify) - - return json.dumps(data) - - except DecodeError as e: - raise DecodeError("There was an error decoding the token: %s" % e) - - -def build_argparser(): - - usage = """ - Encodes or decodes JSON Web Tokens based on input. - - %(prog)s [options] [options] input - - Decoding examples: - - %(prog)s --key=secret decode json.web.token - %(prog)s decode --no-verify json.web.token - - Encoding requires the key option and takes space separated key/value pairs - separated by equals (=) as input. Examples: - - %(prog)s --key=secret encode iss=me exp=1302049071 - %(prog)s --key=secret encode foo=bar exp=+10 - - The exp key is special and can take an offset to current Unix time. - - %(prog)s --key=secret --header='{"typ":"jwt", "alg":"RS256"}' encode is=me - - The header option can be provided for input to encode in the jwt. The format - requires the header be enclosed in single quote and key/value pairs with double - quotes. - """ - - arg_parser = argparse.ArgumentParser(prog="pyjwt", usage=usage) - - arg_parser.add_argument( - "-v", "--version", action="version", version="%(prog)s " + __version__ - ) - - arg_parser.add_argument( - "--key", - dest="key", - metavar="KEY", - default=None, - help="set the secret key to sign with", - ) - - arg_parser.add_argument( - "--alg", - dest="algorithm", - metavar="ALG", - default="HS256", - help="set crypto algorithm to sign with. default=HS256", - ) - - arg_parser.add_argument( - "--header", - dest="header", - metavar="HEADER", - default=None, - help="set jwt header", - ) - - subparsers = arg_parser.add_subparsers( - title="PyJWT subcommands", - description="valid subcommands", - help="additional help", - ) - - # Encode subcommand - encode_parser = subparsers.add_parser( - "encode", help="use to encode a supplied payload" - ) - - payload_help = """Payload to encode. Must be a space separated list of key/value - pairs separated by equals (=) sign.""" - - encode_parser.add_argument("payload", nargs="+", help=payload_help) - encode_parser.set_defaults(func=encode_payload) - - # Decode subcommand - decode_parser = subparsers.add_parser( - "decode", help="use to decode a supplied JSON web token" - ) - decode_parser.add_argument( - "token", help="JSON web token to decode.", nargs="?" - ) - - decode_parser.add_argument( - "-n", - "--no-verify", - action="store_false", - dest="verify", - default=True, - help="ignore signature and claims verification on decode", - ) - - decode_parser.set_defaults(func=decode_payload) - - return arg_parser - - -def main(): - arg_parser = build_argparser() - - try: - arguments = arg_parser.parse_args(sys.argv[1:]) - - output = arguments.func(arguments) - - print(output) - except Exception as e: - print("There was an unforseen error: ", e) - arg_parser.print_help() diff --git a/tests/test_cli.py b/tests/test_cli.py deleted file mode 100644 index ba6528089..000000000 --- a/tests/test_cli.py +++ /dev/null @@ -1,177 +0,0 @@ -import argparse -import json -import sys - -import pytest - -import jwt -from jwt.__main__ import build_argparser, decode_payload, encode_payload, main - - -class TestCli: - def test_build_argparse(self): - args = ["--key", "1234", "encode", "name=Vader"] - parser = build_argparser() - parsed_args = parser.parse_args(args) - - assert parsed_args.key == "1234" - - def test_encode_payload_raises_value_error_key_is_required(self): - encode_args = ["encode", "name=Vader", "job=Sith"] - parser = build_argparser() - - args = parser.parse_args(encode_args) - - with pytest.raises(ValueError) as excinfo: - encode_payload(args) - - assert "Key is required when encoding" in str(excinfo.value) - - def test_encode_header_raises_value_error_bad_dict(self): - encode_args = [ - "--key=secret", - "--header=dfsfd", - "encode", - "name=Vader", - "job=Sith", - ] - parser = build_argparser() - - args = parser.parse_args(encode_args) - - with pytest.raises(ValueError) as excinfo: - encode_payload(args) - - assert "Error loading header:" in str(excinfo.value) - - def test_decode_payload_raises_decoded_error(self): - decode_args = ["--key", "1234", "decode", "wrong-token"] - parser = build_argparser() - - args = parser.parse_args(decode_args) - - with pytest.raises(jwt.DecodeError) as excinfo: - decode_payload(args) - - assert "There was an error decoding the token" in str(excinfo.value) - - def test_decode_payload_raises_decoded_error_isatty(self, monkeypatch): - def patched_sys_stdin_read(): - raise jwt.DecodeError() - - decode_args = ["--key", "1234", "decode", "wrong-token"] - parser = build_argparser() - - args = parser.parse_args(decode_args) - - monkeypatch.setattr(sys.stdin, "isatty", lambda: True) - monkeypatch.setattr(sys.stdin, "read", patched_sys_stdin_read) - - with pytest.raises(jwt.DecodeError) as excinfo: - decode_payload(args) - - assert "There was an error decoding the token" in str(excinfo.value) - - def test_decode_payload_terminal_tty(self, monkeypatch): - encode_args = [ - "--key=secret-key", - '--header={"alg":"HS256"}', - "encode", - "name=hello-world", - ] - parser = build_argparser() - parsed_encode_args = parser.parse_args(encode_args) - token = encode_payload(parsed_encode_args) - - decode_args = ["--key=secret-key", "decode"] - parsed_decode_args = parser.parse_args(decode_args) - - monkeypatch.setattr(sys.stdin, "isatty", lambda: True) - monkeypatch.setattr(sys.stdin, "readline", lambda: token) - - actual = json.loads(decode_payload(parsed_decode_args)) - assert actual["name"] == "hello-world" - - def test_decode_payload_raises_terminal_not_a_tty(self, monkeypatch): - decode_args = ["--key", "1234", "decode"] - parser = build_argparser() - args = parser.parse_args(decode_args) - - monkeypatch.setattr(sys.stdin, "isatty", lambda: False) - - with pytest.raises(IOError) as excinfo: - decode_payload(args) - assert "Cannot read from stdin: terminal not a TTY" in str( - excinfo.value - ) - - @pytest.mark.parametrize( - "key,header,name,job,exp,verify", - [ - ("1234", "{}", "Vader", "Sith", None, None), - ("4567", '{"typ":"test"}', "Anakin", "Jedi", "+1", None), - ("4321", "", "Padme", "Queen", "4070926800", "true"), - ], - ) - def test_encode_decode(self, key, header, name, job, exp, verify): - encode_args = [ - "--key={0}".format(key), - "--header={0}".format(header), - "encode", - "name={0}".format(name), - "job={0}".format(job), - ] - if exp: - encode_args.append("exp={0}".format(exp)) - if verify: - encode_args.append("verify={0}".format(verify)) - - parser = build_argparser() - parsed_encode_args = parser.parse_args(encode_args) - token = encode_payload(parsed_encode_args) - assert token is not None - assert token != "" - - decode_args = ["--key={0}".format(key), "decode", token] - parser = build_argparser() - parsed_decode_args = parser.parse_args(decode_args) - - actual = json.loads(decode_payload(parsed_decode_args)) - expected = {"job": job, "name": name} - assert actual["name"] == expected["name"] - assert actual["job"] == expected["job"] - - @pytest.mark.parametrize( - "key,name,job,exp,verify", - [ - ("1234", "Vader", "Sith", None, None), - ("4567", "Anakin", "Jedi", "+1", None), - ("4321", "Padme", "Queen", "4070926800", "true"), - ], - ) - def test_main(self, monkeypatch, key, name, job, exp, verify): - args = [ - "test_cli.py", - "--key={0}".format(key), - "encode", - "name={0}".format(name), - "job={0}".format(job), - ] - if exp: - args.append("exp={0}".format(exp)) - if verify: - args.append("verify={0}".format(verify)) - monkeypatch.setattr(sys, "argv", args) - main() - - def test_main_throw_exception(self, monkeypatch, capsys): - def patched_argparser_parse_args(self, args): - raise Exception("NOOOOOOOOOOO!") - - monkeypatch.setattr( - argparse.ArgumentParser, "parse_args", patched_argparser_parse_args - ) - main() - out, _ = capsys.readouterr() - - assert "NOOOOOOOOOOO!" in out From 24a5b2ff84dec9f109e3e32a10e5a079cb3e7ca8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Padilla?= Date: Sat, 28 Mar 2020 15:27:39 -0400 Subject: [PATCH 16/29] Handle deprecations --- jwt/api_jws.py | 25 +---- jwt/api_jwt.py | 30 ++---- tests/test_api_jws.py | 130 +++++++++++++------------ tests/test_api_jwt.py | 214 ++++++++++++++++++++++++++++++------------ tests/test_jwt.py | 4 +- 5 files changed, 239 insertions(+), 164 deletions(-) diff --git a/jwt/api_jws.py b/jwt/api_jws.py index 6a1569ef7..7ed4467e6 100644 --- a/jwt/api_jws.py +++ b/jwt/api_jws.py @@ -1,6 +1,5 @@ import binascii import json -import warnings from .algorithms import requires_cryptography # NOQA from .algorithms import Algorithm, get_default_algorithms, has_crypto @@ -132,7 +131,6 @@ def decode( self, jwt, # type: str key="", # type: str - verify=True, # type: bool algorithms=None, # type: List[str] options=None, # type: Dict complete=False, # type: bool @@ -143,23 +141,14 @@ def decode( verify_signature = merged_options["verify_signature"] if verify_signature and not algorithms: - warnings.warn( - "It is strongly recommended that you pass in a " + raise DecodeError( + "It is required that you pass in a " + 'value for the "algorithms" argument when calling decode(). ' - + "This argument will be mandatory in a future version.", - DeprecationWarning, ) payload, signing_input, header, signature = self._load(jwt) - if not verify: - warnings.warn( - "The verify parameter is deprecated. " - "Please use verify_signature in options instead.", - DeprecationWarning, - stacklevel=2, - ) - elif verify_signature: + if verify_signature: self._verify_signature( payload, signing_input, header, signature, key, algorithms ) @@ -225,13 +214,7 @@ def _load(self, jwt): return (payload, signing_input, header, signature) def _verify_signature( - self, - payload, - signing_input, - header, - signature, - key="", - algorithms=None, + self, payload, signing_input, header, signature, key, algorithms ): alg = header.get("alg") diff --git a/jwt/api_jwt.py b/jwt/api_jwt.py index 122204ac2..874968339 100644 --- a/jwt/api_jwt.py +++ b/jwt/api_jwt.py @@ -1,5 +1,4 @@ import json -import warnings from calendar import timegm from datetime import datetime, timedelta @@ -77,7 +76,6 @@ def decode( self, jwt, # type: str key="", # type: str - verify=True, # type: bool algorithms=None, # type: List[str] options=None, # type: Dict complete=False, # type: bool @@ -85,20 +83,18 @@ def decode( ): # type: (...) -> Dict[str, Any] - if verify and not algorithms: - warnings.warn( - "It is strongly recommended that you pass in a " - + 'value for the "algorithms" argument when calling decode(). ' - + "This argument will be mandatory in a future version.", - DeprecationWarning, - ) - payload, _, _, _ = self._load(jwt) if options is None: - options = {"verify_signature": verify} + options = {"verify_signature": True} else: - options.setdefault("verify_signature", verify) + options.setdefault("verify_signature", True) + + 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(). ' + ) decoded = super(PyJWT, self).decode( jwt, @@ -119,7 +115,7 @@ def decode( if not isinstance(payload, dict): raise DecodeError("Invalid payload string: must be a json object") - if verify: + if options["verify_signature"]: merged_options = merge_dict(self.options, options) self._validate_claims(payload, merged_options, **kwargs) @@ -133,14 +129,6 @@ def _validate_claims( self, payload, options, audience=None, issuer=None, leeway=0, **kwargs ): - if "verify_expiration" in kwargs: - options["verify_exp"] = kwargs.get("verify_expiration", True) - warnings.warn( - "The verify_expiration parameter is deprecated. " - "Please use verify_exp in options instead.", - DeprecationWarning, - ) - if isinstance(leeway, timedelta): leeway = leeway.total_seconds() diff --git a/tests/test_api_jws.py b/tests/test_api_jws.py index 55db6072d..e2e51db08 100644 --- a/tests/test_api_jws.py +++ b/tests/test_api_jws.py @@ -88,8 +88,8 @@ def test_options_must_be_dict(self, jws): def test_encode_decode(self, jws, payload): secret = "secret" - jws_message = jws.encode(payload, secret) - decoded_payload = jws.decode(jws_message, secret) + jws_message = jws.encode(payload, secret, algorithm="HS256") + decoded_payload = jws.decode(jws_message, secret, algorithms=["HS256"]) assert decoded_payload == payload @@ -98,7 +98,7 @@ def test_decode_fails_when_alg_is_not_on_method_algorithms_param( ): secret = "secret" jws_token = jws.encode(payload, secret, algorithm="HS256") - jws.decode(jws_token, secret) + jws.decode(jws_token, secret, algorithms=["HS256"]) with pytest.raises(InvalidAlgorithmError): jws.decode(jws_token, secret, algorithms=["HS384"]) @@ -111,7 +111,7 @@ def test_decode_works_with_unicode_token(self, jws): ".tvagLDLoaiJKxOKqpBXSEGy7SYSifZhjntgm9ctpyj8" ) - jws.decode(unicode_jws, secret) + jws.decode(unicode_jws, secret, algorithms=["HS256"]) def test_decode_missing_segments_throws_exception(self, jws): secret = "secret" @@ -122,7 +122,7 @@ def test_decode_missing_segments_throws_exception(self, jws): ) # Missing segment with pytest.raises(DecodeError) as context: - jws.decode(example_jws, secret) + jws.decode(example_jws, secret, algorithms=["HS256"]) exception = context.value assert str(exception) == "Not enough segments" @@ -132,7 +132,7 @@ def test_decode_invalid_token_type_is_none(self, jws): example_secret = "secret" with pytest.raises(DecodeError) as context: - jws.decode(example_jws, example_secret) + jws.decode(example_jws, example_secret, algorithms=["HS256"]) exception = context.value assert "Invalid token type" in str(exception) @@ -142,7 +142,7 @@ def test_decode_invalid_token_type_is_int(self, jws): example_secret = "secret" with pytest.raises(DecodeError) as context: - jws.decode(example_jws, example_secret) + jws.decode(example_jws, example_secret, algorithms=["HS256"]) exception = context.value assert "Invalid token type" in str(exception) @@ -156,7 +156,7 @@ def test_decode_with_non_mapping_header_throws_exception(self, jws): ) with pytest.raises(DecodeError) as context: - jws.decode(example_jws, secret) + jws.decode(example_jws, secret, algorithms=["HS256"]) exception = context.value assert str(exception) == "Invalid header string: must be a json object" @@ -181,7 +181,7 @@ def test_decode_algorithm_param_should_be_case_sensitive(self, jws): ) with pytest.raises(InvalidAlgorithmError) as context: - jws.decode(example_jws, "secret") + jws.decode(example_jws, "secret", algorithms=["hs256"]) exception = context.value assert str(exception) == "Algorithm not supported" @@ -193,11 +193,11 @@ def test_bad_secret(self, jws, payload): with pytest.raises(DecodeError) as excinfo: # Backward compat for ticket #315 - jws.decode(jws_message, bad_secret) + jws.decode(jws_message, bad_secret, algorithms=["HS256"]) assert "Signature verification failed" == str(excinfo.value) with pytest.raises(InvalidSignatureError) as excinfo: - jws.decode(jws_message, bad_secret) + jws.decode(jws_message, bad_secret, algorithms=["HS256"]) assert "Signature verification failed" == str(excinfo.value) def test_decodes_valid_jws(self, jws, payload): @@ -208,7 +208,9 @@ def test_decodes_valid_jws(self, jws, payload): b"gEW0pdU4kxPthjtehYdhxB9mMOGajt1xCKlGGXDJ8PM" ) - decoded_payload = jws.decode(example_jws, example_secret) + decoded_payload = jws.decode( + example_jws, example_secret, algorithms=["HS256"] + ) assert decoded_payload == payload @@ -228,7 +230,9 @@ def test_decodes_valid_es384_jws(self, jws): b"eyJoZWxsbyI6IndvcmxkIn0.TORyNQab_MoXM7DvNKaTwbrJr4UY" b"d2SsX8hhlnWelQFmPFSf_JzC2EbLnar92t-bXsDovzxp25ExazrVHkfPkQ" ) - decoded_payload = jws.decode(example_jws, example_pubkey) + decoded_payload = jws.decode( + example_jws, example_pubkey, algorithms=["ES256"] + ) json_payload = json.loads(force_unicode(decoded_payload)) assert json_payload == example_payload @@ -256,7 +260,9 @@ def test_decodes_valid_rs384_jws(self, jws): b"uwmrtSWCBUjiN8sqJ00CDgycxKqHfUndZbEAOjcCAhBr" b"qWW3mSVivUfubsYbwUdUG3fSRPjaUPcpe8A" ) - decoded_payload = jws.decode(example_jws, example_pubkey) + decoded_payload = jws.decode( + example_jws, example_pubkey, algorithms=["RS384"] + ) json_payload = json.loads(force_unicode(decoded_payload)) assert json_payload == example_payload @@ -269,24 +275,19 @@ def test_load_verify_valid_jws(self, jws, payload): b"SIr03zM64awWRdPrAM_61QWsZchAtgDV3pphfHPPWkI" ) - decoded_payload = jws.decode(example_jws, key=example_secret) + decoded_payload = jws.decode( + example_jws, key=example_secret, algorithms=["HS256"] + ) assert decoded_payload == payload def test_allow_skip_verification(self, jws, payload): right_secret = "foo" jws_message = jws.encode(payload, right_secret) - decoded_payload = jws.decode(jws_message, verify=False) - - assert decoded_payload == payload - - def test_verify_false_deprecated(self, jws, recwarn): - example_jws = ( - b"eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9" - b".eyJoZWxsbyI6ICJ3b3JsZCJ9" - b".tvagLDLoaiJKxOKqpBXSEGy7SYSifZhjntgm9ctpyj8" + decoded_payload = jws.decode( + jws_message, options={"verify_signature": False} ) - pytest.deprecated_call(jws.decode, example_jws, verify=False) + assert decoded_payload == payload def test_decode_with_optional_algorithms(self, jws): example_secret = "secret" @@ -296,7 +297,13 @@ def test_decode_with_optional_algorithms(self, jws): b"SIr03zM64awWRdPrAM_61QWsZchAtgDV3pphfHPPWkI" ) - pytest.deprecated_call(jws.decode, example_jws, key=example_secret) + with pytest.raises(DecodeError) as exc: + jws.decode(example_jws, key=example_secret) + + assert ( + 'It is required that you pass in a value for the "algorithms" argument when calling decode().' + in str(exc.value) + ) def test_decode_no_algorithms_verify_signature_false(self, jws): example_secret = "secret" @@ -306,23 +313,22 @@ def test_decode_no_algorithms_verify_signature_false(self, jws): b"SIr03zM64awWRdPrAM_61QWsZchAtgDV3pphfHPPWkI" ) - try: - pytest.deprecated_call( - jws.decode, - example_jws, - key=example_secret, - options={"verify_signature": False}, - ) - except pytest.fail.Exception: - pass - else: - assert False, "Unexpected DeprecationWarning raised." + jws.decode( + example_jws, + key=example_secret, + options={"verify_signature": False}, + ) def test_load_no_verification(self, jws, payload): right_secret = "foo" jws_message = jws.encode(payload, right_secret) - decoded_payload = jws.decode(jws_message, key=None, verify=False) + decoded_payload = jws.decode( + jws_message, + key=None, + algorithms=["HS256"], + options={"verify_signature": False}, + ) assert decoded_payload == payload @@ -331,14 +337,14 @@ def test_no_secret(self, jws, payload): jws_message = jws.encode(payload, right_secret) with pytest.raises(DecodeError): - jws.decode(jws_message) + jws.decode(jws_message, algorithms=["HS256"]) def test_verify_signature_with_no_secret(self, jws, payload): right_secret = "foo" jws_message = jws.encode(payload, right_secret) with pytest.raises(DecodeError) as exc: - jws.decode(jws_message) + jws.decode(jws_message, algorithms=["HS256"]) assert "Signature verification" in str(exc.value) @@ -352,7 +358,7 @@ def test_verify_signature_with_no_algo_header_throws_exception( ) with pytest.raises(InvalidAlgorithmError): - jws.decode(example_jws, "secret") + jws.decode(example_jws, "secret", algorithms=["HS256"]) def test_invalid_crypto_alg(self, jws, payload): with pytest.raises(NotImplementedError): @@ -369,7 +375,7 @@ def test_missing_crypto_library_better_error_messages(self, jws, payload): def test_unicode_secret(self, jws, payload): secret = "\xc2" jws_message = jws.encode(payload, secret) - decoded_payload = jws.decode(jws_message, secret) + decoded_payload = jws.decode(jws_message, secret, algorithms=["HS256"]) assert decoded_payload == payload @@ -377,7 +383,7 @@ def test_nonascii_secret(self, jws, payload): secret = "\xc2" # char value that ascii codec cannot decode jws_message = jws.encode(payload, secret) - decoded_payload = jws.decode(jws_message, secret) + decoded_payload = jws.decode(jws_message, secret, algorithms=["HS256"]) assert decoded_payload == payload @@ -385,7 +391,7 @@ def test_bytes_secret(self, jws, payload): secret = b"\xc2" # char value that ascii codec cannot decode jws_message = jws.encode(payload, secret) - decoded_payload = jws.decode(jws_message, secret) + decoded_payload = jws.decode(jws_message, secret, algorithms=["HS256"]) assert decoded_payload == payload @@ -398,7 +404,7 @@ def test_decode_invalid_header_padding(self, jws): example_secret = "secret" with pytest.raises(DecodeError) as exc: - jws.decode(example_jws, example_secret) + jws.decode(example_jws, example_secret, algorithms=["HS256"]) assert "header padding" in str(exc.value) @@ -411,7 +417,7 @@ def test_decode_invalid_header_string(self, jws): example_secret = "secret" with pytest.raises(DecodeError) as exc: - jws.decode(example_jws, example_secret) + jws.decode(example_jws, example_secret, algorithms=["HS256"]) assert "Invalid header" in str(exc.value) @@ -424,7 +430,7 @@ def test_decode_invalid_payload_padding(self, jws): example_secret = "secret" with pytest.raises(DecodeError) as exc: - jws.decode(example_jws, example_secret) + jws.decode(example_jws, example_secret, algorithms=["HS256"]) assert "Invalid payload padding" in str(exc.value) @@ -437,7 +443,7 @@ def test_decode_invalid_crypto_padding(self, jws): example_secret = "secret" with pytest.raises(DecodeError) as exc: - jws.decode(example_jws, example_secret) + jws.decode(example_jws, example_secret, algorithms=["HS256"]) assert "Invalid crypto padding" in str(exc.value) @@ -445,13 +451,13 @@ def test_decode_with_algo_none_should_fail(self, jws, payload): jws_message = jws.encode(payload, key=None, algorithm=None) with pytest.raises(DecodeError): - jws.decode(jws_message) + jws.decode(jws_message, algorithms=["none"]) def test_decode_with_algo_none_and_verify_false_should_pass( self, jws, payload ): jws_message = jws.encode(payload, key=None, algorithm=None) - jws.decode(jws_message, verify=False) + jws.decode(jws_message, options={"verify_signature": False}) def test_get_unverified_header_returns_header_values(self, jws, payload): jws_message = jws.encode( @@ -499,7 +505,7 @@ def test_encode_decode_with_rsa_sha256(self, jws, payload): force_bytes(rsa_pub_file.read()), backend=default_backend() ) - jws.decode(jws_message, pub_rsakey) + jws.decode(jws_message, pub_rsakey, algorithms=["RS256"]) # string-formatted key with open("tests/keys/testkey_rsa", "r") as rsa_priv_file: @@ -508,7 +514,7 @@ def test_encode_decode_with_rsa_sha256(self, jws, payload): with open("tests/keys/testkey_rsa.pub", "r") as rsa_pub_file: pub_rsakey = rsa_pub_file.read() - jws.decode(jws_message, pub_rsakey) + jws.decode(jws_message, pub_rsakey, algorithms=["RS256"]) @pytest.mark.skipif( not has_crypto, reason="Not supported without cryptography library" @@ -527,7 +533,7 @@ def test_encode_decode_with_rsa_sha384(self, jws, payload): pub_rsakey = load_ssh_public_key( force_bytes(rsa_pub_file.read()), backend=default_backend() ) - jws.decode(jws_message, pub_rsakey) + jws.decode(jws_message, pub_rsakey, algorithms=["RS384"]) # string-formatted key with open("tests/keys/testkey_rsa", "r") as rsa_priv_file: @@ -536,7 +542,7 @@ def test_encode_decode_with_rsa_sha384(self, jws, payload): with open("tests/keys/testkey_rsa.pub", "r") as rsa_pub_file: pub_rsakey = rsa_pub_file.read() - jws.decode(jws_message, pub_rsakey) + jws.decode(jws_message, pub_rsakey, algorithms=["RS384"]) @pytest.mark.skipif( not has_crypto, reason="Not supported without cryptography library" @@ -555,7 +561,7 @@ def test_encode_decode_with_rsa_sha512(self, jws, payload): pub_rsakey = load_ssh_public_key( force_bytes(rsa_pub_file.read()), backend=default_backend() ) - jws.decode(jws_message, pub_rsakey) + jws.decode(jws_message, pub_rsakey, algorithms=["RS512"]) # string-formatted key with open("tests/keys/testkey_rsa", "r") as rsa_priv_file: @@ -564,7 +570,7 @@ def test_encode_decode_with_rsa_sha512(self, jws, payload): with open("tests/keys/testkey_rsa.pub", "r") as rsa_pub_file: pub_rsakey = rsa_pub_file.read() - jws.decode(jws_message, pub_rsakey) + jws.decode(jws_message, pub_rsakey, algorithms=["RS512"]) def test_rsa_related_algorithms(self, jws): jws = PyJWS() @@ -603,7 +609,7 @@ def test_encode_decode_with_ecdsa_sha256(self, jws, payload): pub_eckey = load_pem_public_key( force_bytes(ec_pub_file.read()), backend=default_backend() ) - jws.decode(jws_message, pub_eckey) + jws.decode(jws_message, pub_eckey, algorithms=["ES256"]) # string-formatted key with open("tests/keys/testkey_ec", "r") as ec_priv_file: @@ -612,7 +618,7 @@ def test_encode_decode_with_ecdsa_sha256(self, jws, payload): with open("tests/keys/testkey_ec.pub", "r") as ec_pub_file: pub_eckey = ec_pub_file.read() - jws.decode(jws_message, pub_eckey) + jws.decode(jws_message, pub_eckey, algorithms=["ES256"]) @pytest.mark.skipif( not has_crypto, reason="Can't run without cryptography library" @@ -632,7 +638,7 @@ def test_encode_decode_with_ecdsa_sha384(self, jws, payload): pub_eckey = load_pem_public_key( force_bytes(ec_pub_file.read()), backend=default_backend() ) - jws.decode(jws_message, pub_eckey) + jws.decode(jws_message, pub_eckey, algorithms=["ES384"]) # string-formatted key with open("tests/keys/testkey_ec", "r") as ec_priv_file: @@ -641,7 +647,7 @@ def test_encode_decode_with_ecdsa_sha384(self, jws, payload): with open("tests/keys/testkey_ec.pub", "r") as ec_pub_file: pub_eckey = ec_pub_file.read() - jws.decode(jws_message, pub_eckey) + jws.decode(jws_message, pub_eckey, algorithms=["ES384"]) @pytest.mark.skipif( not has_crypto, reason="Can't run without cryptography library" @@ -660,7 +666,7 @@ def test_encode_decode_with_ecdsa_sha512(self, jws, payload): pub_eckey = load_pem_public_key( force_bytes(ec_pub_file.read()), backend=default_backend() ) - jws.decode(jws_message, pub_eckey) + jws.decode(jws_message, pub_eckey, algorithms=["ES512"]) # string-formatted key with open("tests/keys/testkey_ec", "r") as ec_priv_file: @@ -669,7 +675,7 @@ def test_encode_decode_with_ecdsa_sha512(self, jws, payload): with open("tests/keys/testkey_ec.pub", "r") as ec_pub_file: pub_eckey = ec_pub_file.read() - jws.decode(jws_message, pub_eckey) + jws.decode(jws_message, pub_eckey, algorithms=["ES512"]) def test_ecdsa_related_algorithms(self, jws): jws = PyJWS() diff --git a/tests/test_api_jwt.py b/tests/test_api_jwt.py index 4cc5ca783..1efc2186f 100644 --- a/tests/test_api_jwt.py +++ b/tests/test_api_jwt.py @@ -41,7 +41,9 @@ def test_decodes_valid_jwt(self, jwt): b".eyJoZWxsbyI6ICJ3b3JsZCJ9" b".tvagLDLoaiJKxOKqpBXSEGy7SYSifZhjntgm9ctpyj8" ) - decoded_payload = jwt.decode(example_jwt, example_secret) + decoded_payload = jwt.decode( + example_jwt, example_secret, algorithms=["HS256"] + ) assert decoded_payload == example_payload @@ -54,7 +56,9 @@ def test_load_verify_valid_jwt(self, jwt): b".tvagLDLoaiJKxOKqpBXSEGy7SYSifZhjntgm9ctpyj8" ) - decoded_payload = jwt.decode(example_jwt, key=example_secret) + decoded_payload = jwt.decode( + example_jwt, key=example_secret, algorithms=["HS256"] + ) assert decoded_payload == example_payload @@ -67,7 +71,7 @@ def test_decode_invalid_payload_string(self, jwt): example_secret = "secret" with pytest.raises(DecodeError) as exc: - jwt.decode(example_jwt, example_secret) + jwt.decode(example_jwt, example_secret, algorithms=["HS256"]) assert "Invalid payload string" in str(exc.value) @@ -80,7 +84,7 @@ def test_decode_with_non_mapping_payload_throws_exception(self, jwt): ) with pytest.raises(DecodeError) as context: - jwt.decode(example_jwt, secret) + jwt.decode(example_jwt, secret, algorithms=["HS256"]) exception = context.value assert ( @@ -96,7 +100,7 @@ def test_decode_with_invalid_audience_param_throws_exception(self, jwt): ) with pytest.raises(TypeError) as context: - jwt.decode(example_jwt, secret, audience=1) + jwt.decode(example_jwt, secret, audience=1, algorithms=["HS256"]) exception = context.value assert str(exception) == "audience must be a string, iterable, or None" @@ -110,7 +114,12 @@ def test_decode_with_nonlist_aud_claim_throws_exception(self, jwt): ) with pytest.raises(InvalidAudienceError) as context: - jwt.decode(example_jwt, secret, audience="my_audience") + jwt.decode( + example_jwt, + secret, + audience="my_audience", + algorithms=["HS256"], + ) exception = context.value assert str(exception) == "Invalid claim format in token" @@ -124,7 +133,12 @@ def test_decode_with_invalid_aud_list_member_throws_exception(self, jwt): ) with pytest.raises(InvalidAudienceError) as context: - jwt.decode(example_jwt, secret, audience="my_audience") + jwt.decode( + example_jwt, + secret, + audience="my_audience", + algorithms=["HS256"], + ) exception = context.value assert str(exception) == "Invalid claim format in token" @@ -134,7 +148,10 @@ def test_encode_bad_type(self, jwt): types = ["string", tuple(), list(), 42, set()] for t in types: - pytest.raises(TypeError, lambda: jwt.encode(t, "secret")) + pytest.raises( + TypeError, + lambda: jwt.encode(t, "secret", algorithms=["HS256"]), + ) def test_decode_raises_exception_if_exp_is_not_int(self, jwt): # >>> jwt.encode({'exp': 'not-an-int'}, 'secret') @@ -145,7 +162,7 @@ def test_decode_raises_exception_if_exp_is_not_int(self, jwt): ) with pytest.raises(DecodeError) as exc: - jwt.decode(example_jwt, "secret") + jwt.decode(example_jwt, "secret", algorithms=["HS256"]) assert "exp" in str(exc.value) @@ -158,7 +175,7 @@ def test_decode_raises_exception_if_iat_is_not_int(self, jwt): ) with pytest.raises(InvalidIssuedAtError): - jwt.decode(example_jwt, "secret") + jwt.decode(example_jwt, "secret", algorithms=["HS256"]) def test_decode_raises_exception_if_nbf_is_not_int(self, jwt): # >>> jwt.encode({'nbf': 'not-an-int'}, 'secret') @@ -169,7 +186,7 @@ def test_decode_raises_exception_if_nbf_is_not_int(self, jwt): ) with pytest.raises(DecodeError): - jwt.decode(example_jwt, "secret") + jwt.decode(example_jwt, "secret", algorithms=["HS256"]) def test_encode_datetime(self, jwt): secret = "secret" @@ -180,7 +197,9 @@ def test_encode_datetime(self, jwt): "nbf": current_datetime, } jwt_message = jwt.encode(payload, secret) - decoded_payload = jwt.decode(jwt_message, secret, leeway=1) + decoded_payload = jwt.decode( + jwt_message, secret, leeway=1, algorithms=["HS256"] + ) assert decoded_payload["exp"] == timegm( current_datetime.utctimetuple() @@ -209,7 +228,9 @@ def test_decodes_valid_es256_jwt(self, jwt): b"d2SsX8hhlnWelQFmPFSf_JzC2EbLnar92t-bXsDovzxp25ExazrVHkfPkQ" ) - decoded_payload = jwt.decode(example_jwt, example_pubkey) + decoded_payload = jwt.decode( + example_jwt, example_pubkey, algorithms=["ES256"] + ) assert decoded_payload == example_payload # 'Control' RSA JWT created by another library. @@ -235,7 +256,9 @@ def test_decodes_valid_rs384_jwt(self, jwt): b"uwmrtSWCBUjiN8sqJ00CDgycxKqHfUndZbEAOjcCAhBr" b"qWW3mSVivUfubsYbwUdUG3fSRPjaUPcpe8A" ) - decoded_payload = jwt.decode(example_jwt, example_pubkey) + decoded_payload = jwt.decode( + example_jwt, example_pubkey, algorithms=["RS384"] + ) assert decoded_payload == example_payload @@ -245,7 +268,7 @@ def test_decode_with_expiration(self, jwt, payload): jwt_message = jwt.encode(payload, secret) with pytest.raises(ExpiredSignatureError): - jwt.decode(jwt_message, secret) + jwt.decode(jwt_message, secret, algorithms=["HS256"]) def test_decode_with_notbefore(self, jwt, payload): payload["nbf"] = utc_timestamp() + 10 @@ -253,21 +276,31 @@ def test_decode_with_notbefore(self, jwt, payload): jwt_message = jwt.encode(payload, secret) with pytest.raises(ImmatureSignatureError): - jwt.decode(jwt_message, secret) + jwt.decode(jwt_message, secret, algorithms=["HS256"]) def test_decode_skip_expiration_verification(self, jwt, payload): payload["exp"] = time.time() - 1 secret = "secret" jwt_message = jwt.encode(payload, secret) - jwt.decode(jwt_message, secret, options={"verify_exp": False}) + jwt.decode( + jwt_message, + secret, + algorithms=["HS256"], + options={"verify_exp": False}, + ) def test_decode_skip_notbefore_verification(self, jwt, payload): payload["nbf"] = time.time() + 10 secret = "secret" jwt_message = jwt.encode(payload, secret) - jwt.decode(jwt_message, secret, options={"verify_nbf": False}) + jwt.decode( + jwt_message, + secret, + algorithms=["HS256"], + options={"verify_nbf": False}, + ) def test_decode_with_expiration_with_leeway(self, jwt, payload): payload["exp"] = utc_timestamp() - 2 @@ -278,12 +311,16 @@ def test_decode_with_expiration_with_leeway(self, jwt, payload): # With 3 seconds leeway, should be ok for leeway in (3, timedelta(seconds=3)): - jwt.decode(jwt_message, secret, leeway=leeway) + jwt.decode( + jwt_message, secret, leeway=leeway, algorithms=["HS256"] + ) # With 1 seconds, should fail for leeway in (1, timedelta(seconds=1)): with pytest.raises(ExpiredSignatureError): - jwt.decode(jwt_message, secret, leeway=leeway) + jwt.decode( + jwt_message, secret, leeway=leeway, algorithms=["HS256"] + ) def test_decode_with_notbefore_with_leeway(self, jwt, payload): payload["nbf"] = utc_timestamp() + 10 @@ -291,37 +328,47 @@ def test_decode_with_notbefore_with_leeway(self, jwt, payload): jwt_message = jwt.encode(payload, secret) # With 13 seconds leeway, should be ok - jwt.decode(jwt_message, secret, leeway=13) + jwt.decode(jwt_message, secret, leeway=13, algorithms=["HS256"]) with pytest.raises(ImmatureSignatureError): - jwt.decode(jwt_message, secret, leeway=1) + jwt.decode(jwt_message, secret, leeway=1, algorithms=["HS256"]) def test_check_audience_when_valid(self, jwt): payload = {"some": "payload", "aud": "urn:me"} token = jwt.encode(payload, "secret") - jwt.decode(token, "secret", audience="urn:me") + jwt.decode(token, "secret", audience="urn:me", algorithms=["HS256"]) def test_check_audience_list_when_valid(self, jwt): payload = {"some": "payload", "aud": "urn:me"} token = jwt.encode(payload, "secret") - jwt.decode(token, "secret", audience=["urn:you", "urn:me"]) + jwt.decode( + token, + "secret", + audience=["urn:you", "urn:me"], + algorithms=["HS256"], + ) def test_check_audience_none_specified(self, jwt): payload = {"some": "payload", "aud": "urn:me"} token = jwt.encode(payload, "secret") with pytest.raises(InvalidAudienceError): - jwt.decode(token, "secret") + jwt.decode(token, "secret", algorithms=["HS256"]) def test_raise_exception_invalid_audience_list(self, jwt): payload = {"some": "payload", "aud": "urn:me"} token = jwt.encode(payload, "secret") with pytest.raises(InvalidAudienceError): - jwt.decode(token, "secret", audience=["urn:you", "urn:him"]) + jwt.decode( + token, + "secret", + audience=["urn:you", "urn:him"], + algorithms=["HS256"], + ) def test_check_audience_in_array_when_valid(self, jwt): payload = {"some": "payload", "aud": ["urn:me", "urn:someone-else"]} token = jwt.encode(payload, "secret") - jwt.decode(token, "secret", audience="urn:me") + jwt.decode(token, "secret", audience="urn:me", algorithms=["HS256"]) def test_raise_exception_invalid_audience(self, jwt): payload = {"some": "payload", "aud": "urn:someone-else"} @@ -329,7 +376,9 @@ def test_raise_exception_invalid_audience(self, jwt): token = jwt.encode(payload, "secret") with pytest.raises(InvalidAudienceError): - jwt.decode(token, "secret", audience="urn-me") + jwt.decode( + token, "secret", audience="urn-me", algorithms=["HS256"] + ) def test_raise_exception_invalid_audience_in_array(self, jwt): payload = { @@ -340,7 +389,9 @@ def test_raise_exception_invalid_audience_in_array(self, jwt): token = jwt.encode(payload, "secret") with pytest.raises(InvalidAudienceError): - jwt.decode(token, "secret", audience="urn:me") + jwt.decode( + token, "secret", audience="urn:me", algorithms=["HS256"] + ) def test_raise_exception_token_without_issuer(self, jwt): issuer = "urn:wrong" @@ -350,7 +401,7 @@ def test_raise_exception_token_without_issuer(self, jwt): token = jwt.encode(payload, "secret") with pytest.raises(MissingRequiredClaimError) as exc: - jwt.decode(token, "secret", issuer=issuer) + jwt.decode(token, "secret", issuer=issuer, algorithms=["HS256"]) assert exc.value.claim == "iss" @@ -359,7 +410,9 @@ def test_raise_exception_token_without_audience(self, jwt): token = jwt.encode(payload, "secret") with pytest.raises(MissingRequiredClaimError) as exc: - jwt.decode(token, "secret", audience="urn:me") + jwt.decode( + token, "secret", audience="urn:me", algorithms=["HS256"] + ) assert exc.value.claim == "aud" @@ -367,7 +420,7 @@ def test_check_issuer_when_valid(self, jwt): issuer = "urn:foo" payload = {"some": "payload", "iss": "urn:foo"} token = jwt.encode(payload, "secret") - jwt.decode(token, "secret", issuer=issuer) + jwt.decode(token, "secret", issuer=issuer, algorithms=["HS256"]) def test_raise_exception_invalid_issuer(self, jwt): issuer = "urn:wrong" @@ -377,12 +430,17 @@ def test_raise_exception_invalid_issuer(self, jwt): token = jwt.encode(payload, "secret") with pytest.raises(InvalidIssuerError): - jwt.decode(token, "secret", issuer=issuer) + jwt.decode(token, "secret", issuer=issuer, algorithms=["HS256"]) def test_skip_check_audience(self, jwt): payload = {"some": "payload", "aud": "urn:me"} token = jwt.encode(payload, "secret") - jwt.decode(token, "secret", options={"verify_aud": False}) + jwt.decode( + token, + "secret", + options={"verify_aud": False}, + algorithms=["HS256"], + ) def test_skip_check_exp(self, jwt): payload = { @@ -390,7 +448,12 @@ def test_skip_check_exp(self, jwt): "exp": datetime.utcnow() - timedelta(days=1), } token = jwt.encode(payload, "secret") - jwt.decode(token, "secret", options={"verify_exp": False}) + jwt.decode( + token, + "secret", + options={"verify_exp": False}, + algorithms=["HS256"], + ) def test_decode_should_raise_error_if_exp_required_but_not_present( self, jwt @@ -402,7 +465,12 @@ def test_decode_should_raise_error_if_exp_required_but_not_present( token = jwt.encode(payload, "secret") with pytest.raises(MissingRequiredClaimError) as exc: - jwt.decode(token, "secret", options={"require_exp": True}) + jwt.decode( + token, + "secret", + options={"require_exp": True}, + algorithms=["HS256"], + ) assert exc.value.claim == "exp" @@ -416,7 +484,12 @@ def test_decode_should_raise_error_if_iat_required_but_not_present( token = jwt.encode(payload, "secret") with pytest.raises(MissingRequiredClaimError) as exc: - jwt.decode(token, "secret", options={"require_iat": True}) + jwt.decode( + token, + "secret", + options={"require_iat": True}, + algorithms=["HS256"], + ) assert exc.value.claim == "iat" @@ -430,7 +503,12 @@ def test_decode_should_raise_error_if_nbf_required_but_not_present( token = jwt.encode(payload, "secret") with pytest.raises(MissingRequiredClaimError) as exc: - jwt.decode(token, "secret", options={"require_nbf": True}) + jwt.decode( + token, + "secret", + options={"require_nbf": True}, + algorithms=["HS256"], + ) assert exc.value.claim == "nbf" @@ -440,7 +518,12 @@ def test_skip_check_signature(self, jwt): ".eyJzb21lIjoicGF5bG9hZCJ9" ".4twFt5NiznN84AWoo1d7KO1T_yoc0Z6XOpOVswacPZA" ) - jwt.decode(token, "secret", options={"verify_signature": False}) + jwt.decode( + token, + "secret", + options={"verify_signature": False}, + algorithms=["HS256"], + ) def test_skip_check_iat(self, jwt): payload = { @@ -448,7 +531,12 @@ def test_skip_check_iat(self, jwt): "iat": datetime.utcnow() + timedelta(days=1), } token = jwt.encode(payload, "secret") - jwt.decode(token, "secret", options={"verify_iat": False}) + jwt.decode( + token, + "secret", + options={"verify_iat": False}, + algorithms=["HS256"], + ) def test_skip_check_nbf(self, jwt): payload = { @@ -456,7 +544,12 @@ def test_skip_check_nbf(self, jwt): "nbf": datetime.utcnow() + timedelta(days=1), } token = jwt.encode(payload, "secret") - jwt.decode(token, "secret", options={"verify_nbf": False}) + jwt.decode( + token, + "secret", + options={"verify_nbf": False}, + algorithms=["HS256"], + ) def test_custom_json_encoder(self, jwt): class CustomJSONEncoder(json.JSONEncoder): @@ -468,42 +561,47 @@ def default(self, o): data = {"some_decimal": Decimal("2.2")} with pytest.raises(TypeError): - jwt.encode(data, "secret") + jwt.encode(data, "secret", algorithms=["HS256"]) token = jwt.encode(data, "secret", json_encoder=CustomJSONEncoder) - payload = jwt.decode(token, "secret") + payload = jwt.decode(token, "secret", algorithms=["HS256"]) assert payload == {"some_decimal": "it worked"} - def test_decode_with_verify_expiration_kwarg(self, jwt, payload): + def test_decode_with_verify_exp_option(self, jwt, payload): payload["exp"] = utc_timestamp() - 1 secret = "secret" jwt_message = jwt.encode(payload, secret) - pytest.deprecated_call( - jwt.decode, jwt_message, secret, verify_expiration=False + jwt.decode( + jwt_message, + secret, + algorithms=["HS256"], + options={"verify_exp": False}, ) with pytest.raises(ExpiredSignatureError): - pytest.deprecated_call( - jwt.decode, jwt_message, secret, verify_expiration=True + jwt.decode( + jwt_message, + secret, + algorithms=["HS256"], + options={"verify_exp": True}, ) def test_decode_with_optional_algorithms(self, jwt, payload): secret = "secret" jwt_message = jwt.encode(payload, secret) - pytest.deprecated_call(jwt.decode, jwt_message, secret) + with pytest.raises(DecodeError) as exc: + jwt.decode(jwt_message, secret) + + assert ( + 'It is required that you pass in a value for the "algorithms" argument when calling decode().' + in str(exc.value) + ) - def test_decode_no_algorithms_verify_false(self, jwt, payload): + def test_decode_no_algorithms_verify_signature_false(self, jwt, payload): secret = "secret" jwt_message = jwt.encode(payload, secret) - try: - pytest.deprecated_call( - jwt.decode, jwt_message, secret, verify=False - ) - except pytest.fail.Exception: - pass - else: - assert False, "Unexpected DeprecationWarning raised." + jwt.decode(jwt_message, secret, options={"verify_signature": False}) diff --git a/tests/test_jwt.py b/tests/test_jwt.py index db96f46ab..126fc9b7d 100644 --- a/tests/test_jwt.py +++ b/tests/test_jwt.py @@ -13,7 +13,7 @@ def test_encode_decode(): payload = {"iss": "jeff", "exp": utc_timestamp() + 15, "claim": "insanity"} secret = "secret" - jwt_message = jwt.encode(payload, secret) - decoded_payload = jwt.decode(jwt_message, secret) + jwt_message = jwt.encode(payload, secret, algorithm="HS256") + decoded_payload = jwt.decode(jwt_message, secret, algorithms=["HS256"]) assert decoded_payload == payload From ced392c70fe659f349607c063af31210d1768a91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Padilla?= Date: Mon, 30 Mar 2020 15:38:36 -0400 Subject: [PATCH 17/29] Add some tests for PyJWKClient --- jwt/jwks_client.py | 4 +- pyproject.toml | 2 +- setup.py | 6 ++- tests/test_jwks_client.py | 108 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 tests/test_jwks_client.py diff --git a/jwt/jwks_client.py b/jwt/jwks_client.py index fab993c87..0625c6d48 100644 --- a/jwt/jwks_client.py +++ b/jwt/jwks_client.py @@ -60,6 +60,8 @@ def get_signing_key(self, kid): return signing_key def get_signing_key_from_jwt(self, token): - unverified = decode_token(token, verify=False, complete=True) + unverified = decode_token( + token, complete=True, options={"verify_signature": False} + ) header = unverified.get("header") return self.get_signing_key(header.get("kid")) diff --git a/pyproject.toml b/pyproject.toml index 1cf228133..19421d9d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,4 +11,4 @@ use_parentheses=true combine_as_imports=true known_first_party="jwt" -known_third_party=["pytest", "setuptools", "sphinx_rtd_theme"] +known_third_party=["pytest", "requests_mock", "setuptools", "sphinx_rtd_theme"] diff --git a/setup.py b/setup.py index 99bbc0979..2c1945a3e 100755 --- a/setup.py +++ b/setup.py @@ -34,7 +34,11 @@ def get_version(package): EXTRAS_REQUIRE = { "jwks-client": ["requests"], - "tests": ["pytest>=4.0.1,<5.0.0", "pytest-cov>=2.6.0,<3.0.0"], + "tests": [ + "pytest>=4.0.1,<5.0.0", + "pytest-cov>=2.6.0,<3.0.0", + "requests-mock>=1.7.0,<2.0.0", + ], "cryptography": ["cryptography >= 1.4"], } diff --git a/tests/test_jwks_client.py b/tests/test_jwks_client.py new file mode 100644 index 000000000..c308c89ae --- /dev/null +++ b/tests/test_jwks_client.py @@ -0,0 +1,108 @@ +import pytest +import requests_mock + +import jwt +from jwt import PyJWKClient +from jwt.api_jwk import PyJWK +from jwt.exceptions import PyJWKClientError + + +@pytest.fixture +def mocked_response(): + return { + "keys": [ + { + "alg": "RS256", + "kty": "RSA", + "use": "sig", + "n": "0wtlJRY9-ru61LmOgieeI7_rD1oIna9QpBMAOWw8wTuoIhFQFwcIi7MFB7IEfelCPj08vkfLsuFtR8cG07EE4uvJ78bAqRjMsCvprWp4e2p7hqPnWcpRpDEyHjzirEJle1LPpjLLVaSWgkbrVaOD0lkWkP1T1TkrOset_Obh8BwtO-Ww-UfrEwxTyz1646AGkbT2nL8PX0trXrmira8GnrCkFUgTUS61GoTdb9bCJ19PLX9Gnxw7J0BtR0GubopXq8KlI0ThVql6ZtVGN2dvmrCPAVAZleM5TVB61m0VSXvGWaF6_GeOhbFoyWcyUmFvzWhBm8Q38vWgsSI7oHTkEw", + "e": "AQAB", + "kid": "NEE1QURBOTM4MzI5RkFDNTYxOTU1MDg2ODgwQ0UzMTk1QjYyRkRFQw", + "x5t": "NEE1QURBOTM4MzI5RkFDNTYxOTU1MDg2ODgwQ0UzMTk1QjYyRkRFQw", + "x5c": [ + "MIIDBzCCAe+gAwIBAgIJNtD9Ozi6j2jJMA0GCSqGSIb3DQEBCwUAMCExHzAdBgNVBAMTFmRldi04N2V2eDlydS5hdXRoMC5jb20wHhcNMTkwNjIwMTU0NDU4WhcNMzMwMjI2MTU0NDU4WjAhMR8wHQYDVQQDExZkZXYtODdldng5cnUuYXV0aDAuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0wtlJRY9+ru61LmOgieeI7/rD1oIna9QpBMAOWw8wTuoIhFQFwcIi7MFB7IEfelCPj08vkfLsuFtR8cG07EE4uvJ78bAqRjMsCvprWp4e2p7hqPnWcpRpDEyHjzirEJle1LPpjLLVaSWgkbrVaOD0lkWkP1T1TkrOset/Obh8BwtO+Ww+UfrEwxTyz1646AGkbT2nL8PX0trXrmira8GnrCkFUgTUS61GoTdb9bCJ19PLX9Gnxw7J0BtR0GubopXq8KlI0ThVql6ZtVGN2dvmrCPAVAZleM5TVB61m0VSXvGWaF6/GeOhbFoyWcyUmFvzWhBm8Q38vWgsSI7oHTkEwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQlGXpmYaXFB7Q3eG69Uhjd4cFp/jAOBgNVHQ8BAf8EBAMCAoQwDQYJKoZIhvcNAQELBQADggEBAIzQOF/h4T5WWAdjhcIwdNS7hS2Deq+UxxkRv+uavj6O9mHLuRG1q5onvSFShjECXaYT6OGibn7Ufw/JSm3+86ZouMYjBEqGh4OvWRkwARy1YTWUVDGpT2HAwtIq3lfYvhe8P4VfZByp1N4lfn6X2NcJflG+Q+mfXNmRFyyft3Oq51PCZyyAkU7bTun9FmMOyBtmJvQjZ8RXgBLvu9nUcZB8yTVoeUEg4cLczQlli/OkiFXhWgrhVr8uF0/9klslMFXtm78iYSgR8/oC+k1pSNd1+ESSt7n6+JiAQ2Co+ZNKta7LTDGAjGjNDymyoCrZpeuYQwwnHYEHu/0khjAxhXo=" + ], + } + ] + } + + +class TestPyJWKClient: + def test_get_jwk_set(self, mocked_response): + url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json" + + with requests_mock.mock() as m: + m.get(url, json=mocked_response) + jwks_client = PyJWKClient(url) + jwk_set = jwks_client.get_jwk_set() + + assert len(jwk_set.keys) == 1 + + def test_get_signing_keys(self, mocked_response): + url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json" + + with requests_mock.mock() as m: + m.get(url, json=mocked_response) + jwks_client = PyJWKClient(url) + signing_keys = jwks_client.get_signing_keys() + + assert len(signing_keys) == 1 + assert isinstance(signing_keys[0], PyJWK) + + def test_get_signing_keys_raises_if_none_found(self, mocked_response): + url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json" + + with requests_mock.mock() as m: + mocked_key = mocked_response["keys"][0].copy() + mocked_key["use"] = "enc" + response = {"keys": [mocked_key]} + m.get(url, json=response) + jwks_client = PyJWKClient(url) + + with pytest.raises(PyJWKClientError) as exc: + jwks_client.get_signing_keys() + + assert "The JWKS endpoint did not contain any signing keys" in str( + exc.value + ) + + def test_get_signing_key(self, mocked_response): + url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json" + kid = "NEE1QURBOTM4MzI5RkFDNTYxOTU1MDg2ODgwQ0UzMTk1QjYyRkRFQw" + + with requests_mock.mock() as m: + m.get(url, json=mocked_response) + jwks_client = PyJWKClient(url) + signing_key = jwks_client.get_signing_key(kid) + + assert isinstance(signing_key, PyJWK) + assert signing_key.key_type == "RSA" + assert signing_key.key_id == kid + assert signing_key.public_key_use == "sig" + + def test_get_signing_key_from_jwt(self, mocked_response): + token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ik5FRTFRVVJCT1RNNE16STVSa0ZETlRZeE9UVTFNRGcyT0Rnd1EwVXpNVGsxUWpZeVJrUkZRdyJ9.eyJpc3MiOiJodHRwczovL2Rldi04N2V2eDlydS5hdXRoMC5jb20vIiwic3ViIjoiYVc0Q2NhNzl4UmVMV1V6MGFFMkg2a0QwTzNjWEJWdENAY2xpZW50cyIsImF1ZCI6Imh0dHBzOi8vZXhwZW5zZXMtYXBpIiwiaWF0IjoxNTcyMDA2OTU0LCJleHAiOjE1NzIwMDY5NjQsImF6cCI6ImFXNENjYTc5eFJlTFdVejBhRTJINmtEME8zY1hCVnRDIiwiZ3R5IjoiY2xpZW50LWNyZWRlbnRpYWxzIn0.PUxE7xn52aTCohGiWoSdMBZGiYAHwE5FYie0Y1qUT68IHSTXwXVd6hn02HTah6epvHHVKA2FqcFZ4GGv5VTHEvYpeggiiZMgbxFrmTEY0csL6VNkX1eaJGcuehwQCRBKRLL3zKmA5IKGy5GeUnIbpPHLHDxr-GXvgFzsdsyWlVQvPX2xjeaQ217r2PtxDeqjlf66UYl6oY6AqNS8DH3iryCvIfCcybRZkc_hdy-6ZMoKT6Piijvk_aXdm7-QQqKJFHLuEqrVSOuBqqiNfVrG27QzAPuPOxvfXTVLXL2jek5meH6n-VWgrBdoMFH93QEszEDowDAEhQPHVs0xj7SIzA" + url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json" + + with requests_mock.mock() as m: + m.get(url, json=mocked_response) + jwks_client = PyJWKClient(url) + signing_key = jwks_client.get_signing_key_from_jwt(token) + + data = jwt.decode( + token, + signing_key.key, + algorithms=["RS256"], + audience="https://expenses-api", + options={"verify_exp": False}, + ) + + assert data == { + "iss": "https://dev-87evx9ru.auth0.com/", + "sub": "aW4Cca79xReLWUz0aE2H6kD0O3cXBVtC@clients", + "aud": "https://expenses-api", + "iat": 1572006954, + "exp": 1572006964, + "azp": "aW4Cca79xReLWUz0aE2H6kD0O3cXBVtC", + "gty": "client-credentials", + } From 771ef81eb698473609d2ff31579edb0c3917934d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Padilla?= Date: Tue, 31 Mar 2020 10:02:49 -0400 Subject: [PATCH 18/29] Skip for now if no cryptography --- tests/test_jwks_client.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_jwks_client.py b/tests/test_jwks_client.py index c308c89ae..f4fb14ad6 100644 --- a/tests/test_jwks_client.py +++ b/tests/test_jwks_client.py @@ -6,6 +6,8 @@ from jwt.api_jwk import PyJWK from jwt.exceptions import PyJWKClientError +from .test_algorithms import has_crypto + @pytest.fixture def mocked_response(): @@ -27,6 +29,9 @@ def mocked_response(): } +@pytest.mark.skipif( + not has_crypto, reason="Not supported without cryptography library" +) class TestPyJWKClient: def test_get_jwk_set(self, mocked_response): url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json" From cb54367ce2eb354b72041c4bebaf913774878ba2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Padilla?= Date: Wed, 1 Apr 2020 00:57:03 -0400 Subject: [PATCH 19/29] Remove cli entrypoint --- setup.py | 1 - 1 file changed, 1 deletion(-) diff --git a/setup.py b/setup.py index 2c1945a3e..76056923f 100755 --- a/setup.py +++ b/setup.py @@ -77,6 +77,5 @@ def get_version(package): ], python_requires=">=3.5", extras_require=EXTRAS_REQUIRE, - entry_points={"console_scripts": ["pyjwt = jwt.__main__:main"]}, options={"bdist_wheel": {"universal": "1"}}, ) From 1ab74ee568e033c37c7eed24db2d37d767f430bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Padilla?= Date: Wed, 1 Apr 2020 08:16:11 -0400 Subject: [PATCH 20/29] Remove reference to Command Line --- README.rst | 15 --------------- docs/index.rst | 14 -------------- 2 files changed, 29 deletions(-) diff --git a/README.rst b/README.rst index 07f6fead0..91859de9d 100644 --- a/README.rst +++ b/README.rst @@ -50,21 +50,6 @@ Usage {'some': 'payload'} -Command line ------------- - -Usage:: - - pyjwt [options] INPUT - -Decoding examples:: - - pyjwt --key=secret decode TOKEN - pyjwt decode --no-verify TOKEN - -See more options executing ``pyjwt --help``. - - Documentation ------------- diff --git a/docs/index.rst b/docs/index.rst index e717bdd50..1076a935a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -42,20 +42,6 @@ Example Usage See :doc:`Usage Examples ` for more examples. -Command line ------------- - -Usage:: - - pyjwt [options] INPUT - -Decoding examples:: - - pyjwt --key=secret decode TOKEN - pyjwt decode --no-verify TOKEN - -See more options executing ``pyjwt --help``. - Index ----- From 3b14696763243942a89b22c5e894b06ddb02951c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Padilla?= Date: Sat, 4 Apr 2020 19:07:16 -0400 Subject: [PATCH 21/29] src layout, tox, gh actions --- .github/workflows/.main.yml | 87 +++++++++++++++++++ .pre-commit-config.yaml | 8 +- docs/conf.py | 1 + jwt/__init__.py | 47 ----------- pyproject.toml | 9 +- setup.py | 144 +++++++++++++++++++------------- src/jwt/__init__.py | 74 ++++++++++++++++ {jwt => src/jwt}/algorithms.py | 1 + {jwt => src/jwt}/api_jwk.py | 0 {jwt => src/jwt}/api_jws.py | 1 + {jwt => src/jwt}/api_jwt.py | 2 + {jwt => src/jwt}/compat.py | 1 + {jwt => src/jwt}/exceptions.py | 0 {jwt => src/jwt}/help.py | 1 + {jwt => src/jwt}/jwks_client.py | 1 + {jwt => src/jwt}/utils.py | 1 + tests/keys/__init__.py | 1 + tests/test_algorithms.py | 1 + tests/test_api_jwk.py | 1 + tests/test_api_jws.py | 2 + tests/test_api_jwt.py | 1 + tests/test_jwks_client.py | 1 + tests/utils.py | 3 +- tox.ini | 44 +++++++++- 24 files changed, 321 insertions(+), 111 deletions(-) create mode 100644 .github/workflows/.main.yml delete mode 100644 jwt/__init__.py create mode 100644 src/jwt/__init__.py rename {jwt => src/jwt}/algorithms.py (99%) rename {jwt => src/jwt}/api_jwk.py (100%) rename {jwt => src/jwt}/api_jws.py (99%) rename {jwt => src/jwt}/api_jwt.py (99%) rename {jwt => src/jwt}/compat.py (99%) rename {jwt => src/jwt}/exceptions.py (100%) rename {jwt => src/jwt}/help.py (99%) rename {jwt => src/jwt}/jwks_client.py (99%) rename {jwt => src/jwt}/utils.py (99%) diff --git a/.github/workflows/.main.yml b/.github/workflows/.main.yml new file mode 100644 index 000000000..d0030accd --- /dev/null +++ b/.github/workflows/.main.yml @@ -0,0 +1,87 @@ +--- +name: CI + +on: + push: + branches: ["master"] + pull_request: + branches: ["master"] + # Allow rebuilds via API. + repository_dispatch: + types: rebuild + +jobs: + tests: + name: "Python ${{ matrix.python-version }}" + runs-on: "ubuntu-latest" + env: + USING_COVERAGE: '3.8' + + strategy: + matrix: + python-version: ["3.5", "3.6", "3.7", "3.8"] + + steps: + - uses: "actions/checkout@v2" + - uses: "actions/setup-python@v1" + with: + python-version: "${{ matrix.python-version }}" + - name: "Install dependencies" + run: | + set -xe + python -VV + python -m site + python -m pip install --upgrade pip setuptools wheel + python -m pip install --upgrade coverage[toml] virtualenv tox tox-gh-actions + + - name: "Run tox targets for ${{ matrix.python-version }}" + run: "python -m tox" + + - name: "Combine coverage" + run: | + set -xe + python -m coverage combine + python -m coverage xml + if: "contains(env.USING_COVERAGE, matrix.python-version)" + - name: "Upload coverage to Codecov" + if: "contains(env.USING_COVERAGE, matrix.python-version)" + uses: "codecov/codecov-action@v1" + with: + fail_ci_if_error: true + + package: + name: "Build & verify package" + runs-on: "ubuntu-latest" + + steps: + - uses: "actions/checkout@v2" + - uses: "actions/setup-python@v1" + with: + python-version: "3.8" + + - name: "Install pep517 and twine" + run: "python -m pip install pep517 twine" + - name: "Build package" + run: "python -m pep517.build --source --binary ." + - name: "List result" + run: "ls -l dist" + - name: "Check long_description" + run: "python -m twine check dist/*" + + install-dev: + strategy: + matrix: + os: ["ubuntu-latest", "windows-latest", "macos-latest"] + + name: "Verify dev env" + runs-on: "${{ matrix.os }}" + + steps: + - uses: "actions/checkout@v2" + - uses: "actions/setup-python@v1" + with: + python-version: "3.8" + - name: "Install in dev mode" + run: "python -m pip install -e .[dev]" + - name: "Import package" + run: "python -c 'import jwt; print(jwt.__version__)'" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6bc4e4ab2..53c271edf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,18 +1,18 @@ repos: - repo: https://github.com/psf/black - rev: 19.3b0 + rev: 19.10b0 hooks: - id: black language_version: python3.8 - repo: https://gitlab.com/pycqa/flake8 - rev: 3.7.8 + rev: 3.7.9 hooks: - id: flake8 language_version: python3.8 - repo: https://github.com/asottile/seed-isort-config - rev: v1.9.3 + rev: v1.9.4 hooks: - id: seed-isort-config @@ -24,7 +24,7 @@ repos: language_version: python3.8 - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.3.0 + rev: v2.4.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer diff --git a/docs/conf.py b/docs/conf.py index 7c897ddbd..28f665cfa 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -21,6 +21,7 @@ # a list of builtin themes. import sphinx_rtd_theme + # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. diff --git a/jwt/__init__.py b/jwt/__init__.py deleted file mode 100644 index cd56eff5d..000000000 --- a/jwt/__init__.py +++ /dev/null @@ -1,47 +0,0 @@ -# -*- coding: utf-8 -*- -# flake8: noqa - -""" -JSON Web Token implementation - -Minimum implementation based on this spec: -https://self-issued.info/docs/draft-jones-json-web-token-01.html -""" - - -__title__ = "pyjwt" -__version__ = "2.0.0.dev" -__author__ = "José Padilla" -__license__ = "MIT" -__copyright__ = "Copyright 2015-2020 José Padilla" - - -from .api_jws import PyJWS -from .api_jwt import ( - PyJWT, - decode, - encode, - get_unverified_header, - register_algorithm, - unregister_algorithm, -) -from .exceptions import ( - DecodeError, - ExpiredSignature, - ExpiredSignatureError, - ImmatureSignatureError, - InvalidAlgorithmError, - InvalidAudience, - InvalidAudienceError, - InvalidIssuedAtError, - InvalidIssuer, - InvalidIssuerError, - InvalidSignatureError, - InvalidTokenError, - MissingRequiredClaimError, - PyJWKClientError, - PyJWKError, - PyJWKSetError, - PyJWTError, -) -from .jwks_client import PyJWKClient diff --git a/pyproject.toml b/pyproject.toml index 19421d9d8..d38f8a9b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,8 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + + [tool.black] line-length = 79 @@ -6,9 +11,11 @@ line-length = 79 atomic=true force_grid_wrap=0 include_trailing_comma=true +lines_after_imports=2 +lines_between_types=1 multi_line_output=3 use_parentheses=true combine_as_imports=true known_first_party="jwt" -known_third_party=["pytest", "requests_mock", "setuptools", "sphinx_rtd_theme"] +known_third_party=["jwt", "pytest", "requests_mock", "setuptools", "sphinx_rtd_theme"] diff --git a/setup.py b/setup.py index 76056923f..89236f9e8 100755 --- a/setup.py +++ b/setup.py @@ -1,37 +1,35 @@ -#!/usr/bin/env python3 - +import codecs import os import re -import sys from setuptools import find_packages, setup -def get_version(package): - """ - Return package version as listed in `__version__` in `init.py`. - """ - with open(os.path.join(package, "__init__.py"), "rb") as init_py: - src = init_py.read().decode("utf-8") - return re.search("__version__ = ['\"]([^'\"]+)['\"]", src).group(1) - - -version = get_version("jwt") - -with open(os.path.join(os.path.dirname(__file__), "README.rst")) as readme: - long_description = readme.read() - -if sys.argv[-1] == "publish": - if os.system("pip freeze | grep twine"): - print("twine not installed.\nUse `pip install twine`.\nExiting.") - sys.exit() - os.system("python setup.py sdist bdist_wheel") - os.system("twine upload dist/*") - print("You probably want to also tag the version now:") - print(" git tag -a {0} -m 'version {0}'".format(version)) - print(" git push --tags") - sys.exit() +############################################################################### +NAME = "PyJWT" +PACKAGES = find_packages(where="src") +META_PATH = os.path.join("src", "jwt", "__init__.py") +KEYWORDS = ["jwt", "json web token", "security", "signing"] +PROJECT_URLS = { + "Documentation": "https://pyjwt.readthedocs.io", + "Bug Tracker": "https://github.com/jpadilla/pyjwt/issues", + "Source Code": "https://github.com/jpadilla/pyjwt", +} +CLASSIFIERS = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Natural Language :: English", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Topic :: Utilities", +] +INSTALL_REQUIRES = [] EXTRAS_REQUIRE = { "jwks-client": ["requests"], "tests": [ @@ -49,33 +47,65 @@ def get_version(package): + ["mypy", "pre-commit"] ) -setup( - name="PyJWT", - version=version, - author="Jose Padilla", - author_email="hello@jpadilla.com", - description="JSON Web Token implementation in Python", - license="MIT", - keywords="jwt json web token security signing", - url="https://github.com/jpadilla/pyjwt", - packages=find_packages( - exclude=["*.tests", "*.tests.*", "tests.*", "tests"] - ), - long_description=long_description, - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", - "Natural Language :: English", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Topic :: Utilities", - ], - python_requires=">=3.5", - extras_require=EXTRAS_REQUIRE, - options={"bdist_wheel": {"universal": "1"}}, -) +############################################################################### + +HERE = os.path.abspath(os.path.dirname(__file__)) + + +def read(*parts): + """ + Build an absolute path from *parts* and and return the contents of the + resulting file. Assume UTF-8 encoding. + """ + with codecs.open(os.path.join(HERE, *parts), "rb", "utf-8") as f: + return f.read() + + +META_FILE = read(META_PATH) + + +def find_meta(meta): + """ + Extract __*meta*__ from META_FILE. + """ + meta_match = re.search( + r"^__{meta}__ = ['\"]([^'\"]*)['\"]".format(meta=meta), META_FILE, re.M + ) + if meta_match: + return meta_match.group(1) + raise RuntimeError("Unable to find __{meta}__ string.".format(meta=meta)) + + +with open(os.path.join(HERE, "README.rst")) as readme: + LONG = readme.read() + + +VERSION = find_meta("version") +URL = find_meta("url") + + +if __name__ == "__main__": + setup( + name=NAME, + description=find_meta("description"), + license=find_meta("license"), + url=URL, + project_urls=PROJECT_URLS, + version=VERSION, + author=find_meta("author"), + author_email=find_meta("email"), + maintainer=find_meta("author"), + maintainer_email=find_meta("email"), + keywords=KEYWORDS, + long_description=LONG, + long_description_content_type="text/x-rst", + packages=PACKAGES, + package_dir={"": "src"}, + python_requires=">=3, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*", + zip_safe=False, + classifiers=CLASSIFIERS, + install_requires=INSTALL_REQUIRES, + extras_require=EXTRAS_REQUIRE, + include_package_data=True, + options={"bdist_wheel": {"universal": "1"}}, + ) diff --git a/src/jwt/__init__.py b/src/jwt/__init__.py new file mode 100644 index 000000000..d985b062d --- /dev/null +++ b/src/jwt/__init__.py @@ -0,0 +1,74 @@ +from .api_jws import PyJWS +from .api_jwt import ( + PyJWT, + decode, + encode, + get_unverified_header, + register_algorithm, + unregister_algorithm, +) +from .exceptions import ( + DecodeError, + ExpiredSignature, + ExpiredSignatureError, + ImmatureSignatureError, + InvalidAlgorithmError, + InvalidAudience, + InvalidAudienceError, + InvalidIssuedAtError, + InvalidIssuer, + InvalidIssuerError, + InvalidSignatureError, + InvalidTokenError, + MissingRequiredClaimError, + PyJWKClientError, + PyJWKError, + PyJWKSetError, + PyJWTError, +) +from .jwks_client import PyJWKClient + + +__version__ = "2.0.0.dev" + +__title__ = "PyJWT" +__description__ = "JSON Web Token implementation in Python" +__url__ = "https://pyjwt.readthedocs.io" +__uri__ = __url__ +__doc__ = __description__ + " <" + __uri__ + ">" + +__author__ = "José Padilla" +__email__ = "hello@jpadilla.com" + +__license__ = "MIT" +__copyright__ = "Copyright 2015-2020 José Padilla" + + +__all__ = [ + "PyJWS", + "PyJWT", + "PyJWKClient", + "decode", + "encode", + "get_unverified_header", + "register_algorithm", + "unregister_algorithm", + # Exceptions + "DecodeError", + "ExpiredSignature", + "ExpiredSignatureError", + "ImmatureSignatureError", + "InvalidAlgorithmError", + "InvalidAudience", + "InvalidAudienceError", + "InvalidIssuedAtError", + "InvalidIssuer", + "InvalidIssuerError", + "InvalidSignatureError", + "InvalidTokenError", + "MissingRequiredClaimError", + "PyJWKClientError", + "PyJWKError", + "PyJWKSetError", + "PyJWTError", +] diff --git a/jwt/algorithms.py b/src/jwt/algorithms.py similarity index 99% rename from jwt/algorithms.py rename to src/jwt/algorithms.py index 26618839a..8ff8a04a6 100644 --- a/jwt/algorithms.py +++ b/src/jwt/algorithms.py @@ -15,6 +15,7 @@ to_base64url_uint, ) + try: from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.serialization import ( diff --git a/jwt/api_jwk.py b/src/jwt/api_jwk.py similarity index 100% rename from jwt/api_jwk.py rename to src/jwt/api_jwk.py diff --git a/jwt/api_jws.py b/src/jwt/api_jws.py similarity index 99% rename from jwt/api_jws.py rename to src/jwt/api_jws.py index 7ed4467e6..d5ee2cddf 100644 --- a/jwt/api_jws.py +++ b/src/jwt/api_jws.py @@ -12,6 +12,7 @@ ) from .utils import base64url_decode, base64url_encode, force_bytes, merge_dict + try: # import required by mypy to perform type checking, not used for normal execution from typing import Callable, Dict, List, Optional, Type, Union # NOQA diff --git a/jwt/api_jwt.py b/src/jwt/api_jwt.py similarity index 99% rename from jwt/api_jwt.py rename to src/jwt/api_jwt.py index 874968339..35e7054af 100644 --- a/jwt/api_jwt.py +++ b/src/jwt/api_jwt.py @@ -1,4 +1,5 @@ import json + from calendar import timegm from datetime import datetime, timedelta @@ -16,6 +17,7 @@ ) from .utils import merge_dict + try: # import required by mypy to perform type checking, not used for normal execution from typing import Any, Callable, Dict, List, Optional, Type, Union # NOQA diff --git a/jwt/compat.py b/src/jwt/compat.py similarity index 99% rename from jwt/compat.py rename to src/jwt/compat.py index b4fec1570..5117f046a 100644 --- a/jwt/compat.py +++ b/src/jwt/compat.py @@ -5,6 +5,7 @@ # flake8: noqa import hmac + text_type = str binary_type = bytes string_types = (str, bytes) diff --git a/jwt/exceptions.py b/src/jwt/exceptions.py similarity index 100% rename from jwt/exceptions.py rename to src/jwt/exceptions.py diff --git a/jwt/help.py b/src/jwt/help.py similarity index 99% rename from jwt/help.py rename to src/jwt/help.py index 0639cb68d..618bcbe46 100644 --- a/jwt/help.py +++ b/src/jwt/help.py @@ -6,6 +6,7 @@ from . import __version__ as pyjwt_version + try: import cryptography # type: ignore except ImportError: diff --git a/jwt/jwks_client.py b/src/jwt/jwks_client.py similarity index 99% rename from jwt/jwks_client.py rename to src/jwt/jwks_client.py index 0625c6d48..ee7fff7cd 100644 --- a/jwt/jwks_client.py +++ b/src/jwt/jwks_client.py @@ -2,6 +2,7 @@ from .api_jwt import decode as decode_token from .exceptions import PyJWKClientError + try: import requests diff --git a/jwt/utils.py b/src/jwt/utils.py similarity index 99% rename from jwt/utils.py rename to src/jwt/utils.py index cc7f56c0a..17e030c2b 100644 --- a/jwt/utils.py +++ b/src/jwt/utils.py @@ -4,6 +4,7 @@ from .compat import binary_type, bytes_from_int, text_type + try: from cryptography.hazmat.primitives.asymmetric.utils import ( decode_dss_signature, diff --git a/tests/keys/__init__.py b/tests/keys/__init__.py index 2cf0b15d0..9e4d4e2a1 100644 --- a/tests/keys/__init__.py +++ b/tests/keys/__init__.py @@ -4,6 +4,7 @@ from jwt.utils import base64url_decode, force_bytes from tests.utils import int_from_bytes + BASE_PATH = os.path.dirname(os.path.abspath(__file__)) diff --git a/tests/test_algorithms.py b/tests/test_algorithms.py index adb1d2a7b..f05dc7dee 100644 --- a/tests/test_algorithms.py +++ b/tests/test_algorithms.py @@ -10,6 +10,7 @@ from .keys import load_hmac_key from .utils import key_path + try: from jwt.algorithms import RSAAlgorithm, ECAlgorithm, RSAPSSAlgorithm from .keys import load_rsa_pub_key, load_ec_pub_key_p_521 diff --git a/tests/test_api_jwk.py b/tests/test_api_jwk.py index 956133e9f..216a03039 100644 --- a/tests/test_api_jwk.py +++ b/tests/test_api_jwk.py @@ -6,6 +6,7 @@ from .utils import key_path + try: from jwt.algorithms import RSAAlgorithm diff --git a/tests/test_api_jws.py b/tests/test_api_jws.py index e2e51db08..a8696e0d1 100644 --- a/tests/test_api_jws.py +++ b/tests/test_api_jws.py @@ -1,4 +1,5 @@ import json + from decimal import Decimal import pytest @@ -13,6 +14,7 @@ ) from jwt.utils import base64url_decode, force_bytes, force_unicode + try: from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.serialization import ( diff --git a/tests/test_api_jwt.py b/tests/test_api_jwt.py index 1efc2186f..8d4ad0c41 100644 --- a/tests/test_api_jwt.py +++ b/tests/test_api_jwt.py @@ -1,5 +1,6 @@ import json import time + from calendar import timegm from datetime import datetime, timedelta from decimal import Decimal diff --git a/tests/test_jwks_client.py b/tests/test_jwks_client.py index f4fb14ad6..d4ae23551 100644 --- a/tests/test_jwks_client.py +++ b/tests/test_jwks_client.py @@ -2,6 +2,7 @@ import requests_mock import jwt + from jwt import PyJWKClient from jwt.api_jwk import PyJWK from jwt.exceptions import PyJWKClientError diff --git a/tests/utils.py b/tests/utils.py index ad39f7590..a6db53ad3 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,5 +1,6 @@ import os import struct + from calendar import timegm from datetime import datetime @@ -29,7 +30,7 @@ def int_from_bytes(data, byteorder, signed=False): result = 0 while len(data) > 0: - digit, = struct.unpack(">I", data[:4]) + (digit,) = struct.unpack(">I", data[:4]) result = (result << 32) + digit data = data[4:] diff --git a/tox.ini b/tox.ini index 8d3358710..fd901f39d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,9 +1,31 @@ +[pytest] +strict = true +addopts = -ra +testpaths = tests +filterwarnings = + once::Warning + ignore:::pympler[.*] + + + +[gh-actions] +python = + 2.7: py27 + 3.5: py35 + 3.6: py36 + 3.7: py37 + 3.8: py38, lint, manifest, typing + + [tox] envlist = lint typing + manifest + pypi-description py{35,36,37,38}-crypto py{35,36,37,38}-nocrypto +isolated_build = True [testenv] @@ -14,11 +36,31 @@ commands = pytest [testenv:typing] +basepython = python3.8 extras = dev -commands = mypy --ignore-missing-imports jwt +commands = mypy --ignore-missing-imports src/jwt [testenv:lint] +basepython = python3.8 extras = dev passenv = HOMEPATH # needed on Windows commands = pre-commit run --all-files + + +[testenv:manifest] +basepython = python3.8 +deps = check-manifest +skip_install = true +commands = check-manifest + + +[testenv:pypi-description] +basepython = python3.8 +skip_install = true +deps = + twine + pip >= 18.0.0 +commands = + pip wheel -w {envtmpdir}/build --no-deps . + twine check {envtmpdir}/build/* From f2dd84a977298e9b4508e10d136949a7fd41c67c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Padilla?= Date: Mon, 6 Apr 2020 09:37:35 -0400 Subject: [PATCH 22/29] Rename workflow --- .github/workflows/.main.yml | 87 ------------------------ .github/workflows/main.yml | 127 ++++++++++++++++++++++++------------ 2 files changed, 87 insertions(+), 127 deletions(-) delete mode 100644 .github/workflows/.main.yml diff --git a/.github/workflows/.main.yml b/.github/workflows/.main.yml deleted file mode 100644 index d0030accd..000000000 --- a/.github/workflows/.main.yml +++ /dev/null @@ -1,87 +0,0 @@ ---- -name: CI - -on: - push: - branches: ["master"] - pull_request: - branches: ["master"] - # Allow rebuilds via API. - repository_dispatch: - types: rebuild - -jobs: - tests: - name: "Python ${{ matrix.python-version }}" - runs-on: "ubuntu-latest" - env: - USING_COVERAGE: '3.8' - - strategy: - matrix: - python-version: ["3.5", "3.6", "3.7", "3.8"] - - steps: - - uses: "actions/checkout@v2" - - uses: "actions/setup-python@v1" - with: - python-version: "${{ matrix.python-version }}" - - name: "Install dependencies" - run: | - set -xe - python -VV - python -m site - python -m pip install --upgrade pip setuptools wheel - python -m pip install --upgrade coverage[toml] virtualenv tox tox-gh-actions - - - name: "Run tox targets for ${{ matrix.python-version }}" - run: "python -m tox" - - - name: "Combine coverage" - run: | - set -xe - python -m coverage combine - python -m coverage xml - if: "contains(env.USING_COVERAGE, matrix.python-version)" - - name: "Upload coverage to Codecov" - if: "contains(env.USING_COVERAGE, matrix.python-version)" - uses: "codecov/codecov-action@v1" - with: - fail_ci_if_error: true - - package: - name: "Build & verify package" - runs-on: "ubuntu-latest" - - steps: - - uses: "actions/checkout@v2" - - uses: "actions/setup-python@v1" - with: - python-version: "3.8" - - - name: "Install pep517 and twine" - run: "python -m pip install pep517 twine" - - name: "Build package" - run: "python -m pep517.build --source --binary ." - - name: "List result" - run: "ls -l dist" - - name: "Check long_description" - run: "python -m twine check dist/*" - - install-dev: - strategy: - matrix: - os: ["ubuntu-latest", "windows-latest", "macos-latest"] - - name: "Verify dev env" - runs-on: "${{ matrix.os }}" - - steps: - - uses: "actions/checkout@v2" - - uses: "actions/setup-python@v1" - with: - python-version: "3.8" - - name: "Install in dev mode" - run: "python -m pip install -e .[dev]" - - name: "Import package" - run: "python -c 'import jwt; print(jwt.__version__)'" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0d78aa51e..127d1771b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,40 +1,87 @@ -# This workflow will install Python dependencies, run tests and lint with a variety of Python versions -# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions - -name: Python package - -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] - -jobs: - build: - - runs-on: ubuntu-latest - strategy: - matrix: - python-version: [3.5, 3.6, 3.7, 3.8] - - steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - name: Lint with flake8 - run: | - pip install flake8 - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest - run: | - pip install pytest - pytest +--- + name: CI + + on: + push: + branches: ["master"] + pull_request: + branches: ["master"] + # Allow rebuilds via API. + repository_dispatch: + types: rebuild + + jobs: + tests: + name: "Python ${{ matrix.python-version }}" + runs-on: "ubuntu-latest" + env: + USING_COVERAGE: '3.8' + + strategy: + matrix: + python-version: ["3.5", "3.6", "3.7", "3.8"] + + steps: + - uses: "actions/checkout@v2" + - uses: "actions/setup-python@v1" + with: + python-version: "${{ matrix.python-version }}" + - name: "Install dependencies" + run: | + set -xe + python -VV + python -m site + python -m pip install --upgrade pip setuptools wheel + python -m pip install --upgrade coverage[toml] virtualenv tox tox-gh-actions + + - name: "Run tox targets for ${{ matrix.python-version }}" + run: "python -m tox" + + - name: "Combine coverage" + run: | + set -xe + python -m coverage combine + python -m coverage xml + if: "contains(env.USING_COVERAGE, matrix.python-version)" + - name: "Upload coverage to Codecov" + if: "contains(env.USING_COVERAGE, matrix.python-version)" + uses: "codecov/codecov-action@v1" + with: + fail_ci_if_error: true + + package: + name: "Build & verify package" + runs-on: "ubuntu-latest" + + steps: + - uses: "actions/checkout@v2" + - uses: "actions/setup-python@v1" + with: + python-version: "3.8" + + - name: "Install pep517 and twine" + run: "python -m pip install pep517 twine" + - name: "Build package" + run: "python -m pep517.build --source --binary ." + - name: "List result" + run: "ls -l dist" + - name: "Check long_description" + run: "python -m twine check dist/*" + + install-dev: + strategy: + matrix: + os: ["ubuntu-latest", "windows-latest", "macos-latest"] + + name: "Verify dev env" + runs-on: "${{ matrix.os }}" + + steps: + - uses: "actions/checkout@v2" + - uses: "actions/setup-python@v1" + with: + python-version: "3.8" + - name: "Install in dev mode" + run: "python -m pip install -e .[dev]" + - name: "Import package" + run: "python -c 'import jwt; print(jwt.__version__)'" From a64a82d8d7fac9352335cd2732d462e8c1946356 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Padilla?= Date: Tue, 7 Apr 2020 22:34:11 -0400 Subject: [PATCH 23/29] Satisfy check-manifest --- .coveragerc | 6 ----- .flake8 | 6 ----- MANIFEST.in | 22 +++++++++++++++--- pyproject.toml | 12 ++++++++++ pytest.ini | 2 -- setup.py | 4 ++-- tests/keys/{testkey_ec => testkey_ec.priv} | 0 tests/keys/{testkey_rsa => testkey_rsa.priv} | 0 tests/test_algorithms.py | 14 ++++++------ tests/test_api_jws.py | 24 ++++++++++---------- tox.ini | 16 +++++++++---- 11 files changed, 64 insertions(+), 42 deletions(-) delete mode 100644 .coveragerc delete mode 100644 .flake8 delete mode 100644 pytest.ini rename tests/keys/{testkey_ec => testkey_ec.priv} (100%) rename tests/keys/{testkey_rsa => testkey_rsa.priv} (100%) diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 0ad0bd9ef..000000000 --- a/.coveragerc +++ /dev/null @@ -1,6 +0,0 @@ -[run] -omit = - tests/* - .tox/* - setup.py - *.egg/* diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 35a155831..000000000 --- a/.flake8 +++ /dev/null @@ -1,6 +0,0 @@ -[flake8] -ignore = E203, E266, E501, W503 -max-line-length = 80 -max-complexity = 18 -select = B,C,E,F,W,T4,B9 -exclude = docs/conf.py,.tox diff --git a/MANIFEST.in b/MANIFEST.in index 144cf6396..7c809ddf4 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,7 +2,23 @@ include README.rst include CHANGELOG.md include LICENSE include AUTHORS +include *.rst *.toml *.yml *.yaml *.md +graft .github +global-exclude *.pyc + +# Tests include tox.ini -recursive-exclude * __pycache__ -recursive-exclude * *.py[co] -graft tests +recursive-include tests *.py +recursive-include tests *.cer +recursive-include tests *.json +recursive-include tests *.pem +recursive-include tests *.pub +recursive-include tests *.priv + +# Documentation +include docs/Makefile docs/docutils.conf +recursive-include docs *.py +recursive-include docs *.rst +recursive-include docs *.css +recursive-include docs *.txt +prune docs/_build diff --git a/pyproject.toml b/pyproject.toml index d38f8a9b1..a00c99d63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,6 +3,18 @@ requires = ["setuptools", "wheel"] build-backend = "setuptools.build_meta" +[tool.coverage.run] +parallel = true +branch = true +source = ["jwt"] + +[tool.coverage.paths] +source = ["src", ".tox/*/site-packages"] + +[tool.coverage.report] +show_missing = true + + [tool.black] line-length = 79 diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index fb1850e4e..000000000 --- a/pytest.ini +++ /dev/null @@ -1,2 +0,0 @@ -[tool:pytest] -addopts = --cov-report term-missing --cov-config=.coveragerc --cov . diff --git a/setup.py b/setup.py index 89236f9e8..8d06af5a9 100755 --- a/setup.py +++ b/setup.py @@ -33,8 +33,8 @@ EXTRAS_REQUIRE = { "jwks-client": ["requests"], "tests": [ - "pytest>=4.0.1,<5.0.0", - "pytest-cov>=2.6.0,<3.0.0", + "coverage[toml]>=5.0.2", + "pytest>=4.3.0,<5.0.0", "requests-mock>=1.7.0,<2.0.0", ], "cryptography": ["cryptography >= 1.4"], diff --git a/tests/keys/testkey_ec b/tests/keys/testkey_ec.priv similarity index 100% rename from tests/keys/testkey_ec rename to tests/keys/testkey_ec.priv diff --git a/tests/keys/testkey_rsa b/tests/keys/testkey_rsa.priv similarity index 100% rename from tests/keys/testkey_rsa rename to tests/keys/testkey_rsa.priv diff --git a/tests/test_algorithms.py b/tests/test_algorithms.py index f05dc7dee..208631cbc 100644 --- a/tests/test_algorithms.py +++ b/tests/test_algorithms.py @@ -143,7 +143,7 @@ def test_rsa_should_parse_pem_public_key(self): def test_rsa_should_accept_pem_private_key_bytes(self): algo = RSAAlgorithm(RSAAlgorithm.SHA256) - with open(key_path("testkey_rsa"), "rb") as pem_key: + with open(key_path("testkey_rsa.priv"), "rb") as pem_key: algo.prepare_key(pem_key.read()) @pytest.mark.skipif( @@ -152,7 +152,7 @@ def test_rsa_should_accept_pem_private_key_bytes(self): def test_rsa_should_accept_unicode_key(self): algo = RSAAlgorithm(RSAAlgorithm.SHA256) - with open(key_path("testkey_rsa"), "r") as rsa_key: + with open(key_path("testkey_rsa.priv"), "r") as rsa_key: algo.prepare_key(force_unicode(rsa_key.read())) @pytest.mark.skipif( @@ -300,7 +300,7 @@ def test_rsa_jwk_public_and_private_keys_should_parse_and_verify(self): def test_rsa_private_key_to_jwk_works_with_from_jwk(self): algo = RSAAlgorithm(RSAAlgorithm.SHA256) - with open(key_path("testkey_rsa"), "r") as rsa_key: + with open(key_path("testkey_rsa.priv"), "r") as rsa_key: orig_key = algo.prepare_key(force_unicode(rsa_key.read())) parsed_key = algo.from_jwk(algo.to_jwk(orig_key)) @@ -431,7 +431,7 @@ def test_rsa_to_jwk_returns_correct_values_for_public_key(self): def test_rsa_to_jwk_returns_correct_values_for_private_key(self): algo = RSAAlgorithm(RSAAlgorithm.SHA256) - with open(key_path("testkey_rsa"), "r") as keyfile: + with open(key_path("testkey_rsa.priv"), "r") as keyfile: priv_key = algo.prepare_key(keyfile.read()) key = algo.to_jwk(priv_key) @@ -518,7 +518,7 @@ def test_ec_should_reject_non_string_key(self): def test_ec_should_accept_unicode_key(self): algo = ECAlgorithm(ECAlgorithm.SHA256) - with open(key_path("testkey_ec"), "r") as ec_key: + with open(key_path("testkey_ec.priv"), "r") as ec_key: algo.prepare_key(force_unicode(ec_key.read())) @pytest.mark.skipif( @@ -527,7 +527,7 @@ def test_ec_should_accept_unicode_key(self): def test_ec_should_accept_pem_private_key_bytes(self): algo = ECAlgorithm(ECAlgorithm.SHA256) - with open(key_path("testkey_ec"), "rb") as ec_key: + with open(key_path("testkey_ec.priv"), "rb") as ec_key: algo.prepare_key(ec_key.read()) @pytest.mark.skipif( @@ -588,7 +588,7 @@ def test_rsa_pss_sign_then_verify_should_return_true(self): message = force_bytes("Hello World!") - with open(key_path("testkey_rsa"), "r") as keyfile: + with open(key_path("testkey_rsa.priv"), "r") as keyfile: priv_key = algo.prepare_key(keyfile.read()) sig = algo.sign(message, priv_key) diff --git a/tests/test_api_jws.py b/tests/test_api_jws.py index a8696e0d1..368e41258 100644 --- a/tests/test_api_jws.py +++ b/tests/test_api_jws.py @@ -494,7 +494,7 @@ def test_get_unverified_header_fails_on_bad_header_types( ) def test_encode_decode_with_rsa_sha256(self, jws, payload): # PEM-formatted RSA key - with open("tests/keys/testkey_rsa", "r") as rsa_priv_file: + with open("tests/keys/testkey_rsa.priv", "r") as rsa_priv_file: priv_rsakey = load_pem_private_key( force_bytes(rsa_priv_file.read()), password=None, @@ -510,7 +510,7 @@ def test_encode_decode_with_rsa_sha256(self, jws, payload): jws.decode(jws_message, pub_rsakey, algorithms=["RS256"]) # string-formatted key - with open("tests/keys/testkey_rsa", "r") as rsa_priv_file: + with open("tests/keys/testkey_rsa.priv", "r") as rsa_priv_file: priv_rsakey = rsa_priv_file.read() jws_message = jws.encode(payload, priv_rsakey, algorithm="RS256") @@ -523,7 +523,7 @@ def test_encode_decode_with_rsa_sha256(self, jws, payload): ) def test_encode_decode_with_rsa_sha384(self, jws, payload): # PEM-formatted RSA key - with open("tests/keys/testkey_rsa", "r") as rsa_priv_file: + with open("tests/keys/testkey_rsa.priv", "r") as rsa_priv_file: priv_rsakey = load_pem_private_key( force_bytes(rsa_priv_file.read()), password=None, @@ -538,7 +538,7 @@ def test_encode_decode_with_rsa_sha384(self, jws, payload): jws.decode(jws_message, pub_rsakey, algorithms=["RS384"]) # string-formatted key - with open("tests/keys/testkey_rsa", "r") as rsa_priv_file: + with open("tests/keys/testkey_rsa.priv", "r") as rsa_priv_file: priv_rsakey = rsa_priv_file.read() jws_message = jws.encode(payload, priv_rsakey, algorithm="RS384") @@ -551,7 +551,7 @@ def test_encode_decode_with_rsa_sha384(self, jws, payload): ) def test_encode_decode_with_rsa_sha512(self, jws, payload): # PEM-formatted RSA key - with open("tests/keys/testkey_rsa", "r") as rsa_priv_file: + with open("tests/keys/testkey_rsa.priv", "r") as rsa_priv_file: priv_rsakey = load_pem_private_key( force_bytes(rsa_priv_file.read()), password=None, @@ -566,7 +566,7 @@ def test_encode_decode_with_rsa_sha512(self, jws, payload): jws.decode(jws_message, pub_rsakey, algorithms=["RS512"]) # string-formatted key - with open("tests/keys/testkey_rsa", "r") as rsa_priv_file: + with open("tests/keys/testkey_rsa.priv", "r") as rsa_priv_file: priv_rsakey = rsa_priv_file.read() jws_message = jws.encode(payload, priv_rsakey, algorithm="RS512") @@ -599,7 +599,7 @@ def test_rsa_related_algorithms(self, jws): ) def test_encode_decode_with_ecdsa_sha256(self, jws, payload): # PEM-formatted EC key - with open("tests/keys/testkey_ec", "r") as ec_priv_file: + with open("tests/keys/testkey_ec.priv", "r") as ec_priv_file: priv_eckey = load_pem_private_key( force_bytes(ec_priv_file.read()), password=None, @@ -614,7 +614,7 @@ def test_encode_decode_with_ecdsa_sha256(self, jws, payload): jws.decode(jws_message, pub_eckey, algorithms=["ES256"]) # string-formatted key - with open("tests/keys/testkey_ec", "r") as ec_priv_file: + with open("tests/keys/testkey_ec.priv", "r") as ec_priv_file: priv_eckey = ec_priv_file.read() jws_message = jws.encode(payload, priv_eckey, algorithm="ES256") @@ -628,7 +628,7 @@ def test_encode_decode_with_ecdsa_sha256(self, jws, payload): def test_encode_decode_with_ecdsa_sha384(self, jws, payload): # PEM-formatted EC key - with open("tests/keys/testkey_ec", "r") as ec_priv_file: + with open("tests/keys/testkey_ec.priv", "r") as ec_priv_file: priv_eckey = load_pem_private_key( force_bytes(ec_priv_file.read()), password=None, @@ -643,7 +643,7 @@ def test_encode_decode_with_ecdsa_sha384(self, jws, payload): jws.decode(jws_message, pub_eckey, algorithms=["ES384"]) # string-formatted key - with open("tests/keys/testkey_ec", "r") as ec_priv_file: + with open("tests/keys/testkey_ec.priv", "r") as ec_priv_file: priv_eckey = ec_priv_file.read() jws_message = jws.encode(payload, priv_eckey, algorithm="ES384") @@ -656,7 +656,7 @@ def test_encode_decode_with_ecdsa_sha384(self, jws, payload): ) def test_encode_decode_with_ecdsa_sha512(self, jws, payload): # PEM-formatted EC key - with open("tests/keys/testkey_ec", "r") as ec_priv_file: + with open("tests/keys/testkey_ec.priv", "r") as ec_priv_file: priv_eckey = load_pem_private_key( force_bytes(ec_priv_file.read()), password=None, @@ -671,7 +671,7 @@ def test_encode_decode_with_ecdsa_sha512(self, jws, payload): jws.decode(jws_message, pub_eckey, algorithms=["ES512"]) # string-formatted key - with open("tests/keys/testkey_ec", "r") as ec_priv_file: + with open("tests/keys/testkey_ec.priv", "r") as ec_priv_file: priv_eckey = ec_priv_file.read() jws_message = jws.encode(payload, priv_eckey, algorithm="ES512") diff --git a/tox.ini b/tox.ini index fd901f39d..131240954 100644 --- a/tox.ini +++ b/tox.ini @@ -7,7 +7,6 @@ filterwarnings = ignore:::pympler[.*] - [gh-actions] python = 2.7: py27 @@ -21,10 +20,11 @@ python = envlist = lint typing - manifest - pypi-description py{35,36,37,38}-crypto py{35,36,37,38}-nocrypto + manifest + pypi-description + coverage-report isolated_build = True @@ -32,7 +32,7 @@ isolated_build = True extras = tests crypto: cryptography -commands = pytest +commands = coverage run -m pytest {posargs} [testenv:typing] @@ -64,3 +64,11 @@ deps = commands = pip wheel -w {envtmpdir}/build --no-deps . twine check {envtmpdir}/build/* + +[testenv:coverage-report] +basepython = python3.8 +skip_install = true +deps = coverage[toml]>=5.0.2 +commands = + coverage combine + coverage report From ede02878583671d5f32ee232464383cef09070d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Padilla?= Date: Tue, 7 Apr 2020 22:48:06 -0400 Subject: [PATCH 24/29] Clean up .gitignore --- .gitignore | 69 ++++++++---------------------------------------------- 1 file changed, 10 insertions(+), 59 deletions(-) diff --git a/.gitignore b/.gitignore index 2d5c9eb30..5e1c4b9a1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,61 +1,12 @@ -# Created by https://www.gitignore.io - -### Python ### -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] - -# C extensions -*.so - -# Distribution / packaging -.Python -env/ -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -*.egg-info/ -.installed.cfg -*.egg - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage +*.egg-info +*.pyc .cache -nosetests.xml -coverage.xml - -# Translations -*.mo -*.pot - -# Django stuff: -*.log - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -.pytest_cache +.coverage* .mypy_cache +.pytest_cache +.tox +build +dist +docs/_build/ +htmlcov +pip-wheel-metadata From 76bd54fbc5d8c3bb9894b8dc96dce0719eba4bf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Padilla?= Date: Tue, 7 Apr 2020 23:53:59 -0400 Subject: [PATCH 25/29] Improve docs setup --- .flake8 | 3 + .readthedocs.yml | 11 ++ docs/conf.py | 246 +++++-------------------------------- docs/faq.rst | 8 -- docs/requirements-docs.txt | 2 - docs/usage.rst | 8 +- setup.py | 3 +- tox.ini | 12 +- 8 files changed, 65 insertions(+), 228 deletions(-) create mode 100644 .flake8 create mode 100644 .readthedocs.yml delete mode 100644 docs/requirements-docs.txt diff --git a/.flake8 b/.flake8 new file mode 100644 index 000000000..f81cf2c7e --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 79 +extend-ignore = E203, E501 diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 000000000..511ae165f --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,11 @@ +--- +version: 2 +python: + # Keep version in sync with tox.ini (docs and gh-actions). + version: 3.7 + + install: + - method: pip + path: . + extra_requirements: + - docs diff --git a/docs/conf.py b/docs/conf.py index 28f665cfa..c7574bdf5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,41 +1,46 @@ # -*- coding: utf-8 -*- -# -# PyJWT documentation build configuration file, created by -# sphinx-quickstart on Thu Oct 22 18:11:10 2015. -# -# This file is execfile()d with the current directory set to its -# containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. - +import codecs import os import re -import shlex -import sys -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. import sphinx_rtd_theme -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# sys.path.insert(0, os.path.abspath('.')) +def read(*parts): + """ + Build an absolute path from *parts* and and return the contents of the + resulting file. Assume UTF-8 encoding. + """ + here = os.path.abspath(os.path.dirname(__file__)) + with codecs.open(os.path.join(here, *parts), "rb", "utf-8") as f: + return f.read() -# -- General configuration ------------------------------------------------ -# If your documentation needs a minimal Sphinx version, state it here. -# needs_sphinx = '1.0' +def find_version(*file_paths): + """ + Build a path from *file_paths* and search for a ``__version__`` + string inside. + """ + version_file = read(*file_paths) + version_match = re.search( + r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M + ) + if version_match: + return version_match.group(1) + raise RuntimeError("Unable to find version string.") + + +# -- General configuration ------------------------------------------------ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = [] +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.doctest", + "sphinx.ext.intersphinx", + "sphinx.ext.todo", +] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] @@ -45,9 +50,6 @@ # source_suffix = ['.rst', '.md'] source_suffix = ".rst" -# The encoding of source files. -# source_encoding = 'utf-8-sig' - # The master toctree document. master_doc = "index" @@ -60,19 +62,11 @@ # |version| and |release|, also used in various other places throughout the # built documents. # -# The short X.Y version. -def get_version(package): - """ - Return package version as listed in `__version__` in `init.py`. - """ - with open(os.path.join("..", package, "__init__.py"), "rb") as init_py: - src = init_py.read().decode("utf-8") - return re.search("__version__ = ['\"]([^'\"]+)['\"]", src).group(1) - - -version = get_version("jwt") # The full version, including alpha/beta/rc tags. -release = version +release = find_version("../src/jwt/__init__.py") + +# The short X.Y version. +version = release.rsplit(u".", 1)[0] # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -81,40 +75,13 @@ def get_version(package): # Usually you set "language" from the command line for these cases. language = None -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -# today = '' -# Else, today_fmt is used as the format for a strftime call. -# today_fmt = '%B %d, %Y' - # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ["_build"] -# The reST default role (used for this markup: `text`) to use for all -# documents. -# default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -# add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -# add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -# show_authors = False - # The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" -# A list of ignored prefixes for module index sorting. -# modindex_common_prefix = [] - -# If true, keep warnings as "system message" paragraphs in the built documents. -# keep_warnings = False - # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False @@ -126,30 +93,6 @@ def get_version(package): html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -# html_theme_options = {} - -# Add any paths that contain custom themes here, relative to this directory. -# html_theme_path = [] - -# The name for this set of Sphinx documents. If None, it defaults to -# " v documentation". -# html_title = None - -# A shorter title for the navigation bar. Default is the same as html_title. -# html_short_title = None - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -# html_logo = None - -# The name of an image file (within the static path) to use as favicon of the -# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -# html_favicon = None - # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". @@ -162,115 +105,9 @@ def get_version(package): ] } -# Add any extra paths that contain custom files (such as robots.txt or -# .htaccess) here, relative to this directory. These files are copied -# directly to the root of the documentation. -# html_extra_path = [] - -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -# html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -# html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -# html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -# html_additional_pages = {} - -# If false, no module index is generated. -# html_domain_indices = True - -# If false, no index is generated. -# html_use_index = True - -# If true, the index is split into individual pages for each letter. -# html_split_index = False - -# If true, links to the reST sources are added to the pages. -# html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -# html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -# html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -# html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -# html_file_suffix = None - -# Language to be used for generating the HTML full-text search index. -# Sphinx supports the following languages: -# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' -# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' -# html_search_language = 'en' - -# A dictionary with options for the search language support, empty by default. -# Now only 'ja' uses this config value -# html_search_options = {'type': 'default'} - -# The name of a javascript file (relative to the configuration directory) that -# implements a search results scorer. If empty, the default will be used. -# html_search_scorer = 'scorer.js' - # Output file base name for HTML help builder. htmlhelp_basename = "PyJWTdoc" -# -- Options for LaTeX output --------------------------------------------- - -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - #'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). - #'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. - #'preamble': '', - # Latex figure (float) alignment - #'figure_align': 'htbp', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - ( - master_doc, - "PyJWT.tex", - u"PyJWT Documentation", - u"José Padilla", - "manual", - ) -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -# latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -# latex_use_parts = False - -# If true, show page references after internal links. -# latex_show_pagerefs = False - -# If true, show URL addresses after external links. -# latex_show_urls = False - -# Documents to append as an appendix to all manuals. -# latex_appendices = [] - -# If false, no module index is generated. -# latex_domain_indices = True - # -- Options for manual page output --------------------------------------- @@ -278,9 +115,6 @@ def get_version(package): # (source start file, name, description, authors, manual section). man_pages = [(master_doc, "pyjwt", u"PyJWT Documentation", [author], 1)] -# If true, show URL addresses after external links. -# man_show_urls = False - # -- Options for Texinfo output ------------------------------------------- @@ -298,15 +132,3 @@ def get_version(package): "Miscellaneous", ) ] - -# Documents to append as an appendix to all manuals. -# texinfo_appendices = [] - -# If false, no module index is generated. -# texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -# texinfo_show_urls = 'footnote' - -# If true, do not generate a @detailmenu in the "Top" node's menu. -# texinfo_no_detailmenu = False diff --git a/docs/faq.rst b/docs/faq.rst index a5eb13965..e8fb177e1 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -28,11 +28,3 @@ extract the public or private keys from a x509 certificate in PEM format. cert_obj = load_pem_x509_certificate(cert_str, default_backend()) public_key = cert_obj.public_key() private_key = cert_obj.private_key() - - -I'm using Google App Engine and can't install `cryptography`, what can I do? ----------------------------------------------------------------------------- - -Some platforms like Google App Engine don't allow you to install libraries -that require C extensions to be built (like `cryptography`). If you're deploying -to one of those environments, you should check out :ref:`legacy-deps` diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt deleted file mode 100644 index 82133027c..000000000 --- a/docs/requirements-docs.txt +++ /dev/null @@ -1,2 +0,0 @@ -sphinx -sphinx_rtd_theme diff --git a/docs/usage.rst b/docs/usage.rst index 131e67bc7..87269e324 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -2,7 +2,7 @@ Usage Examples ============== Encoding & Decoding Tokens with HS256 ---------------------------------- +------------------------------------- .. code-block:: python @@ -14,7 +14,7 @@ Encoding & Decoding Tokens with HS256 {'some': 'payload'} Encoding & Decoding Tokens with RS256 (RSA) ---------------------------------- +------------------------------------------- .. code-block:: python @@ -27,7 +27,7 @@ Encoding & Decoding Tokens with RS256 (RSA) {'some': 'payload'} Specifying Additional Headers ---------------------------------- +----------------------------- .. code-block:: python @@ -36,7 +36,7 @@ Specifying Additional Headers Reading the Claimset without Validation ------------------------------------------ +--------------------------------------- If you wish to read the claimset of a JWT without performing validation of the signature or any of the registered claim names, you can set the ``verify`` diff --git a/setup.py b/setup.py index 8d06af5a9..c672f72c7 100755 --- a/setup.py +++ b/setup.py @@ -31,13 +31,14 @@ ] INSTALL_REQUIRES = [] EXTRAS_REQUIRE = { - "jwks-client": ["requests"], + "docs": ["sphinx", "sphinx-rtd-theme", "zope.interface"], "tests": [ "coverage[toml]>=5.0.2", "pytest>=4.3.0,<5.0.0", "requests-mock>=1.7.0,<2.0.0", ], "cryptography": ["cryptography >= 1.4"], + "jwks-client": ["requests"], } EXTRAS_REQUIRE["dev"] = ( diff --git a/tox.ini b/tox.ini index 131240954..7a140bc63 100644 --- a/tox.ini +++ b/tox.ini @@ -12,7 +12,7 @@ python = 2.7: py27 3.5: py35 3.6: py36 - 3.7: py37 + 3.7: py37, docs 3.8: py38, lint, manifest, typing @@ -23,6 +23,7 @@ envlist = py{35,36,37,38}-crypto py{35,36,37,38}-nocrypto manifest + docs pypi-description coverage-report isolated_build = True @@ -35,6 +36,15 @@ extras = commands = coverage run -m pytest {posargs} +[testenv:docs] +basepython = python3.7 +extras = docs +commands = + sphinx-build -n -T -W -b html -d {envtmpdir}/doctrees docs docs/_build/html + sphinx-build -n -T -W -b doctest -d {envtmpdir}/doctrees docs docs/_build/html + python -m doctest README.rst + + [testenv:typing] basepython = python3.8 extras = dev From c7be83c284e783d7ff484cf1c3e976fd5f5b7eb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Padilla?= Date: Wed, 8 Apr 2020 00:01:58 -0400 Subject: [PATCH 26/29] Return string instead of byte --- README.rst | 4 ++-- docs/index.rst | 5 +++-- src/jwt/api_jws.py | 5 ++++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index 91859de9d..636b49c10 100644 --- a/README.rst +++ b/README.rst @@ -44,8 +44,8 @@ Usage >>> import jwt >>> encoded = jwt.encode({'some': 'payload'}, 'secret', algorithm='HS256') - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzb21lIjoicGF5bG9hZCJ9.4twFt5NiznN84AWoo1d7KO1T_yoc0Z6XOpOVswacPZg' - + >>> print(encoded) + eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZCJ9.Joh1R2dYzkRvDkqv3sygm5YyK8Gi4ShZqbhK2gxcs2U >>> jwt.decode(encoded, 'secret', algorithms=['HS256']) {'some': 'payload'} diff --git a/docs/index.rst b/docs/index.rst index 1076a935a..536d93e6c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -30,12 +30,13 @@ Example Usage ------------- .. code-block:: python +.. doctest:: >>> import jwt >>> encoded_jwt = jwt.encode({'some': 'payload'}, 'secret', algorithm='HS256') - >>> encoded_jwt - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzb21lIjoicGF5bG9hZCJ9.4twFt5NiznN84AWoo1d7KO1T_yoc0Z6XOpOVswacPZg' + >>> print(encoded_jwt) + eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZCJ9.Joh1R2dYzkRvDkqv3sygm5YyK8Gi4ShZqbhK2gxcs2U >>> jwt.decode(encoded_jwt, 'secret', algorithms=['HS256']) {'some': 'payload'} diff --git a/src/jwt/api_jws.py b/src/jwt/api_jws.py index d5ee2cddf..4b18f3f43 100644 --- a/src/jwt/api_jws.py +++ b/src/jwt/api_jws.py @@ -110,6 +110,7 @@ def encode( # Segments signing_input = b".".join(segments) + try: alg_obj = self._algorithms[algorithm] key = alg_obj.prepare_key(key) @@ -126,7 +127,9 @@ def encode( segments.append(base64url_encode(signature)) - return b".".join(segments) + encoded_string = b".".join(segments) + + return encoded_string.decode("utf-8") def decode( self, From 0f560087534f48222fb839780b2665c315627344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Padilla?= Date: Wed, 8 Apr 2020 00:10:40 -0400 Subject: [PATCH 27/29] Update manifest --- MANIFEST.in | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index 7c809ddf4..42de57156 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,18 +2,14 @@ include README.rst include CHANGELOG.md include LICENSE include AUTHORS +include .flake8 include *.rst *.toml *.yml *.yaml *.md graft .github -global-exclude *.pyc +global-exclude *.pyc __pycache__ # Tests include tox.ini -recursive-include tests *.py -recursive-include tests *.cer -recursive-include tests *.json -recursive-include tests *.pem -recursive-include tests *.pub -recursive-include tests *.priv +graft tests # Documentation include docs/Makefile docs/docutils.conf From ab6332b0c81e36bbd004e6104e064985a75e4e4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Padilla?= Date: Wed, 8 Apr 2020 00:29:19 -0400 Subject: [PATCH 28/29] Test in windows --- .github/workflows/main.yml | 13 +++++++------ tox.ini | 10 ++++++++-- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 127d1771b..4146a3981 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,13 +12,14 @@ jobs: tests: - name: "Python ${{ matrix.python-version }}" - runs-on: "ubuntu-latest" + name: "Python ${{ matrix.python-version }} on ${{ matrix.platform }}" + runs-on: "${{ matrix.platform }}" env: USING_COVERAGE: '3.8' strategy: matrix: + platform: ["ubuntu-latest", "windows-latest"] python-version: ["3.5", "3.6", "3.7", "3.8"] steps: @@ -28,7 +29,6 @@ python-version: "${{ matrix.python-version }}" - name: "Install dependencies" run: | - set -xe python -VV python -m site python -m pip install --upgrade pip setuptools wheel @@ -36,15 +36,16 @@ - name: "Run tox targets for ${{ matrix.python-version }}" run: "python -m tox" + env: + PLATFORM: ${{ matrix.platform }} - name: "Combine coverage" run: | - set -xe python -m coverage combine python -m coverage xml - if: "contains(env.USING_COVERAGE, matrix.python-version)" + if: "contains(env.USING_COVERAGE, matrix.python-version) && matrix.platform == 'ubuntu-latest'" - name: "Upload coverage to Codecov" - if: "contains(env.USING_COVERAGE, matrix.python-version)" + if: "contains(env.USING_COVERAGE, matrix.python-version) && matrix.platform == 'ubuntu-latest'" uses: "codecov/codecov-action@v1" with: fail_ci_if_error: true diff --git a/tox.ini b/tox.ini index 7a140bc63..6c1bd0730 100644 --- a/tox.ini +++ b/tox.ini @@ -16,12 +16,18 @@ python = 3.8: py38, lint, manifest, typing +[gh-actions:env] +PLATFORM = + ubuntu-latest: linux + windows-latest: windows + + [tox] envlist = lint typing - py{35,36,37,38}-crypto - py{35,36,37,38}-nocrypto + py{35,36,37,38}-crypto-{linux,windows} + py{35,36,37,38}-nocrypto-{linux,windows} manifest docs pypi-description From 18da31e2050d87fc2159147ad954cbb99c5648ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Padilla?= Date: Wed, 8 Apr 2020 09:32:51 -0400 Subject: [PATCH 29/29] Stop codecov comments --- codecov.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 codecov.yml diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 000000000..60a1e5c12 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,10 @@ +--- +comment: false +coverage: + status: + patch: + default: + target: "100" + project: + default: + target: "100"