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 index 35a155831..f81cf2c7e 100644 --- a/.flake8 +++ b/.flake8 @@ -1,6 +1,3 @@ [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 +max-line-length = 79 +extend-ignore = E203, E501 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0d78aa51e..4146a3981 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,40 +1,88 @@ -# 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 }} 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: + - uses: "actions/checkout@v2" + - uses: "actions/setup-python@v1" + with: + python-version: "${{ matrix.python-version }}" + - name: "Install dependencies" + run: | + 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" + env: + PLATFORM: ${{ matrix.platform }} + + - name: "Combine coverage" + run: | + python -m coverage combine + python -m coverage xml + 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) && matrix.platform == 'ubuntu-latest'" + 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/.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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2617e46bc..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.7 + language_version: python3.8 - repo: https://gitlab.com/pycqa/flake8 - rev: 3.7.8 + rev: 3.7.9 hooks: - id: flake8 - language_version: python3.7 + 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 @@ -21,10 +21,10 @@ 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 + rev: v2.4.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer 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/.travis.yml b/.travis.yml index 5e2dd0c4b..c34de14c7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,14 +1,16 @@ language: python matrix: include: + - 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=lint,typing,py37-crypto,py37-nocrypto,py37-contrib_crypto + env: TOXENV=py37-crypto,py37-nocrypto - 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/MANIFEST.in b/MANIFEST.in index 144cf6396..42de57156 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,7 +2,19 @@ include README.rst include CHANGELOG.md include LICENSE include AUTHORS +include .flake8 +include *.rst *.toml *.yml *.yaml *.md +graft .github +global-exclude *.pyc __pycache__ + +# Tests include tox.ini -recursive-exclude * __pycache__ -recursive-exclude * *.py[co] graft tests + +# 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/README.rst b/README.rst index 07f6fead0..636b49c10 100644 --- a/README.rst +++ b/README.rst @@ -44,27 +44,12 @@ 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'} -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/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" diff --git a/docs/conf.py b/docs/conf.py index 7c897ddbd..c7574bdf5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,40 +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('.')) -# -- General configuration ------------------------------------------------ +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() + -# 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"] @@ -44,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" @@ -59,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. @@ -80,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 @@ -125,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". @@ -161,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 --------------------------------------- @@ -277,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 ------------------------------------------- @@ -297,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/index.rst b/docs/index.rst index e717bdd50..536d93e6c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -30,32 +30,19 @@ 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'} 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 ----- 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/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/jwt/__init__.py b/jwt/__init__.py deleted file mode 100644 index e8e1f4796..000000000 --- a/jwt/__init__.py +++ /dev/null @@ -1,43 +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__ = "1.7.1" -__author__ = "José Padilla" -__license__ = "MIT" -__copyright__ = "Copyright 2015-2018 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, - PyJWTError, -) 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/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/pyproject.toml b/pyproject.toml index 82c796955..a00c99d63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,20 @@ +[build-system] +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 @@ -6,9 +23,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=["Crypto", "ecdsa", "pytest", "setuptools", "sphinx_rtd_theme"] +known_third_party=["jwt", "pytest", "requests_mock", "setuptools", "sphinx_rtd_theme"] 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 4d45f9f6a..c672f72c7 100755 --- a/setup.py +++ b/setup.py @@ -1,74 +1,112 @@ -#!/usr/bin/env python3 - +import codecs import os import re -import sys from setuptools import find_packages, setup -def get_version(package): +############################################################################### + +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 = { + "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"] = ( + EXTRAS_REQUIRE["tests"] + + EXTRAS_REQUIRE["cryptography"] + + EXTRAS_REQUIRE["jwks-client"] + + ["mypy", "pre-commit"] +) + +############################################################################### + +HERE = os.path.abspath(os.path.dirname(__file__)) + + +def read(*parts): """ - Return package version as listed in `__version__` in `init.py`. + Build an absolute path from *parts* and and return the contents of the + resulting file. Assume UTF-8 encoding. """ - 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) + with codecs.open(os.path.join(HERE, *parts), "rb", "utf-8") as f: + return f.read() -version = get_version("jwt") +META_FILE = read(META_PATH) -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() +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)) -EXTRAS_REQUIRE = { - "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"] -) +with open(os.path.join(HERE, "README.rst")) as readme: + LONG = readme.read() -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, - entry_points={"console_scripts": ["pyjwt = jwt.__main__:main"]}, - options={"bdist_wheel": {"universal": "1"}}, -) + +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 84% rename from jwt/algorithms.py rename to src/jwt/algorithms.py index 293a47078..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 ( @@ -40,6 +41,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 @@ -51,7 +54,6 @@ "RS512", "ES256", "ES384", - "ES521", "ES512", "PS256", "PS384", @@ -79,10 +81,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), @@ -292,7 +291,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") @@ -429,6 +433,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/src/jwt/api_jwk.py b/src/jwt/api_jwk.py new file mode 100644 index 000000000..22771f1bb --- /dev/null +++ b/src/jwt/api_jwk.py @@ -0,0 +1,72 @@ +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/src/jwt/api_jws.py similarity index 91% rename from jwt/api_jws.py rename to src/jwt/api_jws.py index 9504c9f69..4b18f3f43 100644 --- a/jwt/api_jws.py +++ b/src/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 @@ -13,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 @@ -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,15 +127,17 @@ 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, jwt, # type: str key="", # type: str - verify=True, # type: bool algorithms=None, # type: List[str] options=None, # type: Dict + complete=False, # type: bool **kwargs ): @@ -142,27 +145,25 @@ 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 ) + if complete: + return { + "payload": payload, + "header": header, + "signature": signature, + } + return payload def get_unverified_header(self, jwt): @@ -217,13 +218,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/src/jwt/api_jwt.py similarity index 89% rename from jwt/api_jwt.py rename to src/jwt/api_jwt.py index 22bfc6a5d..35e7054af 100644 --- a/jwt/api_jwt.py +++ b/src/jwt/api_jwt.py @@ -1,5 +1,5 @@ import json -import warnings + from calendar import timegm from datetime import datetime, timedelta @@ -17,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 @@ -77,57 +78,59 @@ 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 **kwargs ): # 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, 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): 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) + if complete: + decoded["payload"] = payload + return decoded + return payload 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/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 88% rename from jwt/exceptions.py rename to src/jwt/exceptions.py index cd2ca2a25..abe088beb 100644 --- a/jwt/exceptions.py +++ b/src/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/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/src/jwt/jwks_client.py b/src/jwt/jwks_client.py new file mode 100644 index 000000000..ee7fff7cd --- /dev/null +++ b/src/jwt/jwks_client.py @@ -0,0 +1,68 @@ +from .api_jwk import PyJWKSet +from .api_jwt import decode as decode_token +from .exceptions import PyJWKClientError + + +try: + import requests + + has_requests = True +except ImportError: + has_requests = False + + +class PyJWKClient: + def __init__(self, uri): + if not has_requests: + raise PyJWKClientError( + "Missing dependencies for `PyJWKClient`. Run `pip install pyjwt[jwks-client]` to install dependencies." + ) + + 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( + 'Unable to find a signing key that matches: "{}"'.format(kid) + ) + + return signing_key + + def get_signing_key_from_jwt(self, token): + 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/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/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 4a1550b13..000000000 --- a/tests/contrib/test_algorithms.py +++ /dev/null @@ -1,214 +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 - - -@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 diff --git a/tests/keys/__init__.py b/tests/keys/__init__.py index 6b61caa8b..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__)) @@ -44,11 +45,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/keys/testkey_ec b/tests/keys/testkey_ec deleted file mode 100644 index fa93275ff..000000000 --- a/tests/keys/testkey_ec +++ /dev/null @@ -1,7 +0,0 @@ ------BEGIN EC PRIVATE KEY----- -MIHbAgEBBEG4xN/z6gk7bPkEzs1hHOsbs+Gi2lku8YH4LkS4E1q9U9jSOjvEcFNH -m/CQjKi1rtpAb0/WL3p/wXsc26e7zmAA5KAHBgUrgQQAI6GBiQOBhgAEAVnCcDxA -J0v5OJBYFIcTReydEkEIWRvpzYMvv5l8IUOT2SFJiHdWtU45DV4is7+g6bbQanbh -28/1dBLR/kH1stAeAYWeTJ08gxo3M9Q0KinXsXm4c6G24UiGY6WHeWlOPKPa16fz -pwJ62o3XaRrCdGzX+K7TCwahWCTeizrJQAe8UwUY ------END EC PRIVATE KEY----- diff --git a/tests/keys/testkey_ec.priv b/tests/keys/testkey_ec.priv new file mode 100644 index 000000000..c7c0fb7c6 --- /dev/null +++ b/tests/keys/testkey_ec.priv @@ -0,0 +1,5 @@ +-----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/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 79d1b8e4a..208631cbc 100644 --- a/tests/test_algorithms.py +++ b/tests/test_algorithms.py @@ -10,9 +10,10 @@ 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 + from .keys import load_rsa_pub_key, load_ec_pub_key_p_521 has_crypto = True except ImportError: @@ -142,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( @@ -151,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( @@ -190,6 +191,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" ) @@ -211,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)) @@ -342,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) @@ -429,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( @@ -438,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( @@ -499,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) @@ -665,7 +754,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 diff --git a/tests/test_api_jwk.py b/tests/test_api_jwk.py new file mode 100644 index 000000000..216a03039 --- /dev/null +++ b/tests/test_api_jwk.py @@ -0,0 +1,117 @@ +import json + +import pytest + +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) + + 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" + + @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) + + 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: + @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) + + 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" + + @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) + + 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" diff --git a/tests/test_api_jws.py b/tests/test_api_jws.py index 5e1b2eb25..368e41258 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 ( @@ -88,8 +90,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 +100,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 +113,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 +124,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 +134,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 +144,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 +158,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 +183,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 +195,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 +210,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 @@ -224,14 +228,13 @@ 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, algorithms=["ES256"] ) - decoded_payload = jws.decode(example_jws, example_pubkey) json_payload = json.loads(force_unicode(decoded_payload)) assert json_payload == example_payload @@ -259,7 +262,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 @@ -272,24 +277,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" @@ -299,7 +299,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" @@ -309,23 +315,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 @@ -334,14 +339,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) @@ -355,7 +360,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): @@ -372,7 +377,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 @@ -380,7 +385,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 @@ -388,7 +393,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 @@ -401,7 +406,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) @@ -414,7 +419,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) @@ -427,7 +432,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) @@ -440,7 +445,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) @@ -448,13 +453,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( @@ -489,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, @@ -502,23 +507,23 @@ 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: + 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") 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" ) 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, @@ -530,23 +535,23 @@ 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: + 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") 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" ) 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, @@ -558,16 +563,16 @@ 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: + 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") 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() @@ -594,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, @@ -606,16 +611,16 @@ 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: + 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") 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" @@ -623,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, @@ -635,44 +640,44 @@ 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: + 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") 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" ) 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, 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( 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: + 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="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() - jws.decode(jws_message, pub_eckey) + jws.decode(jws_message, pub_eckey, algorithms=["ES512"]) def test_ecdsa_related_algorithms(self, jws): jws = PyJWS() @@ -681,11 +686,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 = ( diff --git a/tests/test_api_jwt.py b/tests/test_api_jwt.py index e4065c69e..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 @@ -41,7 +42,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 +57,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 +72,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 +85,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 +101,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 +115,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 +134,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 +149,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 +163,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 +176,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 +187,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 +198,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() @@ -199,20 +219,19 @@ 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, algorithms=["ES256"] + ) assert decoded_payload == example_payload # 'Control' RSA JWT created by another library. @@ -238,7 +257,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 @@ -248,7 +269,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 @@ -256,21 +277,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 @@ -281,12 +312,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 @@ -294,37 +329,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"} @@ -332,7 +377,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 = { @@ -343,7 +390,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" @@ -353,7 +402,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" @@ -362,7 +411,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" @@ -370,7 +421,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" @@ -380,12 +431,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 = { @@ -393,7 +449,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 @@ -405,7 +466,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" @@ -419,7 +485,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" @@ -433,7 +504,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" @@ -443,7 +519,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 = { @@ -451,7 +532,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 = { @@ -459,7 +545,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): @@ -471,42 +562,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_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 diff --git a/tests/test_jwks_client.py b/tests/test_jwks_client.py new file mode 100644 index 000000000..d4ae23551 --- /dev/null +++ b/tests/test_jwks_client.py @@ -0,0 +1,114 @@ +import pytest +import requests_mock + +import jwt + +from jwt import PyJWKClient +from jwt.api_jwk import PyJWK +from jwt.exceptions import PyJWKClientError + +from .test_algorithms import has_crypto + + +@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=" + ], + } + ] + } + + +@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" + + 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", + } 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 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 c9b1d9271..6c1bd0730 100644 --- a/tox.ini +++ b/tox.ini @@ -1,27 +1,90 @@ +[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, docs + 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}-contrib_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 + coverage-report +isolated_build = True [testenv] -extras = tests -commands = pytest -deps = +extras = + tests crypto: cryptography - contrib_crypto: pycrypto - contrib_crypto: ecdsa +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 -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/* + +[testenv:coverage-report] +basepython = python3.8 +skip_install = true +deps = coverage[toml]>=5.0.2 +commands = + coverage combine + coverage report