├── .gitignore ├── .gitlab-ci.yml ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE.md ├── Makefile ├── README.rst ├── bin └── publish.sh ├── docs ├── Makefile ├── api.rst ├── changes.rst ├── conf.py ├── index.rst ├── install.rst ├── license.rst └── usage.rst ├── poetry.lock ├── pyproject.toml ├── renovate.json ├── sandbox ├── __init__.py ├── manage.py ├── settings.py └── urls.py ├── setup.cfg ├── src └── asymmetric_jwt_auth │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── keys.py │ ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── generate_key_pair.py │ ├── middleware.py │ ├── migrations │ ├── 0001_initial.py │ ├── 0002_publickey_comment.py │ ├── 0003_auto_20151112_1547.py │ ├── 0004_auto_20191104_1628.py │ ├── 0005_auto_20210304_1116.py │ ├── 0006_jwksendpointtrust_last_used_on_alter_publickey_key.py │ └── __init__.py │ ├── models.py │ ├── nonce │ ├── __init__.py │ ├── base.py │ ├── django.py │ └── null.py │ ├── py.typed │ ├── repos │ ├── __init__.py │ ├── base.py │ └── django.py │ ├── tests │ ├── __init__.py │ ├── data.py │ ├── fixtures │ │ ├── dummy_rsa.privkey │ │ ├── dummy_rsa.pub │ │ ├── dummy_rsa_encrypted.privkey │ │ └── dummy_rsa_encrypted.pub │ ├── test_admin.py │ ├── test_commands.py │ ├── test_keys.py │ ├── test_middleware.py │ ├── test_models.py │ ├── test_settings.py │ ├── test_token.py │ ├── test_utils.py │ └── test_views.py │ ├── tokens.py │ ├── urls.py │ ├── utils.py │ └── views.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/sublimetext,osx,linux,python,django 2 | 3 | version.txt 4 | 5 | ### SublimeText ### 6 | # cache files for sublime text 7 | *.tmlanguage.cache 8 | *.tmPreferences.cache 9 | *.stTheme.cache 10 | 11 | # workspace files are user-specific 12 | *.sublime-workspace 13 | 14 | # project files should be checked into the repository, unless a significant 15 | # proportion of contributors will probably not be using SublimeText 16 | # *.sublime-project 17 | 18 | # sftp configuration file 19 | sftp-config.json 20 | 21 | 22 | ### OSX ### 23 | .DS_Store 24 | .AppleDouble 25 | .LSOverride 26 | 27 | # Icon must end with two \r 28 | Icon 29 | 30 | 31 | # Thumbnails 32 | ._* 33 | 34 | # Files that might appear in the root of a volume 35 | .DocumentRevisions-V100 36 | .fseventsd 37 | .Spotlight-V100 38 | .TemporaryItems 39 | .Trashes 40 | .VolumeIcon.icns 41 | 42 | # Directories potentially created on remote AFP share 43 | .AppleDB 44 | .AppleDesktop 45 | Network Trash Folder 46 | Temporary Items 47 | .apdisk 48 | 49 | 50 | ### Linux ### 51 | *~ 52 | 53 | # KDE directory preferences 54 | .directory 55 | 56 | # Linux trash folder which might appear on any partition or disk 57 | .Trash-* 58 | 59 | 60 | ### Python ### 61 | # Byte-compiled / optimized / DLL files 62 | __pycache__/ 63 | *.py[cod] 64 | *$py.class 65 | 66 | # C extensions 67 | *.so 68 | 69 | # Distribution / packaging 70 | .Python 71 | env/ 72 | env3/ 73 | build/ 74 | develop-eggs/ 75 | dist/ 76 | downloads/ 77 | eggs/ 78 | .eggs/ 79 | lib/ 80 | lib64/ 81 | parts/ 82 | sdist/ 83 | var/ 84 | *.egg-info/ 85 | .installed.cfg 86 | *.egg 87 | 88 | # PyInstaller 89 | # Usually these files are written by a python script from a template 90 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 91 | *.manifest 92 | *.spec 93 | 94 | # Installer logs 95 | pip-log.txt 96 | pip-delete-this-directory.txt 97 | 98 | # Unit test / coverage reports 99 | htmlcov/ 100 | .tox/ 101 | .coverage 102 | .coverage.* 103 | .cache 104 | nosetests.xml 105 | coverage.xml 106 | *,cover 107 | 108 | # Translations 109 | *.mo 110 | *.pot 111 | 112 | # Django stuff: 113 | *.log 114 | 115 | # Sphinx documentation 116 | docs/_build/ 117 | 118 | # PyBuilder 119 | target/ 120 | 121 | 122 | ### Django ### 123 | *.log 124 | *.pot 125 | *.pyc 126 | __pycache__/ 127 | local_settings.py 128 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | - test 3 | - release 4 | 5 | cache: 6 | key: "$CI_PROJECT_NAME" 7 | paths: 8 | - $HOME/.cache/pip 9 | 10 | include: 11 | - component: gitlab.com/thelabnyc/thelab-ci-components/precommit@0.4.0 12 | - component: gitlab.com/thelabnyc/thelab-ci-components/publish-gitlab-release@0.4.0 13 | - component: gitlab.com/thelabnyc/thelab-ci-components/publish-to-pypi@0.4.0 14 | 15 | test: 16 | stage: test 17 | image: "registry.gitlab.com/thelabnyc/python:${IMAGE}" 18 | script: 19 | - pip install tox 20 | - tox 21 | coverage: '/^TOTAL.+?(\d+\%)$/' 22 | parallel: 23 | matrix: 24 | - IMAGE: py311 25 | TOX_SKIP_ENV: "^(?!py311-)" 26 | - IMAGE: py312 27 | TOX_SKIP_ENV: "^(?!py312-)" 28 | - IMAGE: py313 29 | TOX_SKIP_ENV: "^(?!py313-)" 30 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://gitlab.com/thelabnyc/thelab-pre-commit-hooks 3 | rev: v0.0.2 4 | hooks: 5 | - id: update-copyright-year 6 | args: 7 | - --file=LICENSE.md 8 | - --pattern=(?P\d{4})\s+Craig Weber 9 | 10 | - repo: https://github.com/pre-commit/pre-commit-hooks 11 | rev: v5.0.0 12 | hooks: 13 | - id: check-json 14 | - id: check-merge-conflict 15 | - id: check-symlinks 16 | - id: check-toml 17 | - id: check-yaml 18 | - id: end-of-file-fixer 19 | - id: trailing-whitespace 20 | 21 | - repo: https://github.com/asottile/pyupgrade 22 | rev: v3.20.0 23 | hooks: 24 | - id: pyupgrade 25 | args: [--py311-plus] 26 | 27 | - repo: https://github.com/adamchainz/django-upgrade 28 | rev: "1.25.0" 29 | hooks: 30 | - id: django-upgrade 31 | 32 | - repo: https://github.com/psf/black 33 | rev: "25.1.0" 34 | hooks: 35 | - id: black 36 | types: [file, python] 37 | 38 | - repo: https://github.com/pycqa/isort 39 | rev: "6.0.1" 40 | hooks: 41 | - id: isort 42 | 43 | - repo: https://github.com/commitizen-tools/commitizen 44 | rev: v4.8.2 45 | hooks: 46 | - id: commitizen 47 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v1.2.0 (2025-04-03) 2 | 3 | ### Feat 4 | 5 | - support django 5.2 6 | - drop Python 3.10. Add Python 3.13 7 | 8 | ### Fix 9 | 10 | - **deps**: update dependency cryptography to >=44.0.2 11 | - **deps**: update dependency cryptography to >=44.0.1 12 | 13 | ### Refactor 14 | 15 | - add django-upgrade precommit hook 16 | - add pyupgrade precommit hook 17 | 18 | ## v1.1.1 (2025-02-05) 19 | 20 | ### Fix 21 | 22 | - project urls 23 | 24 | ## v1.1.0 (2025-02-05) 25 | 26 | ### Feat 27 | 28 | - change default PUBLIC_KEY_REPOSITORIES order to prefer JWKS 29 | - support multiple JWKSEndpointTrusts per user 30 | - add last_used_on timestamp to JWKSEndpointTrust 31 | 32 | ### Perf 33 | 34 | - cache JWKS keys for default time 5 mins 35 | 36 | ## v1.0.3 (2025-01-15) 37 | 38 | ### Fix 39 | 40 | - enable PEP 740 attestations 41 | 42 | ## v1.0.2 (2025-01-14) 43 | 44 | ### Fix 45 | 46 | - add type annotations 47 | 48 | ## v1.0.1 (2024-08-31) 49 | 50 | ## v1.0.1b9 (2024-08-08) 51 | 52 | ## v1.0.0 (2021-05-27) 53 | 54 | ## v0.5.2 (2021-02-17) 55 | 56 | ## v0.5.1 (2021-01-13) 57 | 58 | ## v0.5.0 (2019-12-12) 59 | 60 | ## v0.4.3 (2018-04-23) 61 | 62 | ## v0.4.2 (2018-04-05) 63 | 64 | ## v0.4.1 (2017-12-27) 65 | 66 | ## v0.4.0 (2017-12-27) 67 | 68 | ## v0.3.1 (2024-08-08) 69 | 70 | ## v0.3.0 (2024-08-08) 71 | 72 | ## v0.2.3 (2024-08-08) 73 | 74 | ## v0.2.2 (2024-08-08) 75 | 76 | ## v0.2.1 (2024-08-08) 77 | 78 | ## v0.2.0 (2024-08-08) 79 | 80 | ## v0.1.7 (2024-08-08) 81 | 82 | ## v0.1.6 (2024-08-08) 83 | 84 | ## v0.1.5 (2024-08-08) 85 | 86 | ## v0.1.4 (2024-08-08) 87 | 88 | ## v0.1.3 (2024-08-08) 89 | 90 | ## v0.1.2 (2024-08-08) 91 | 92 | ## v0.1.1 (2024-08-08) 93 | 94 | ## v0.1.0 (2024-08-08) 95 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2015 - 2025 Craig Weber 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 10 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 11 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 12 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 13 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 14 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 15 | PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: install_precommit test_precommit fmt 2 | 3 | install_precommit: 4 | pre-commit install 5 | 6 | test_precommit: install_precommit 7 | pre-commit run --all-files 8 | 9 | fmt: 10 | black . 11 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Asymmetric JWT Authentication 2 | ============================= 3 | 4 | .. image:: https://img.shields.io/pypi/v/asymmetric_jwt_auth.svg 5 | :target: https://pypi.python.org/pypi/asymmetric_jwt_auth 6 | 7 | .. image:: https://gitlab.com/crgwbr/asymmetric_jwt_auth/badges/master/pipeline.svg 8 | :target: https://gitlab.com/crgwbr/asymmetric_jwt_auth/-/commits/master 9 | 10 | .. image:: https://gitlab.com/crgwbr/asymmetric_jwt_auth/badges/master/coverage.svg 11 | :target: https://gitlab.com/crgwbr/asymmetric_jwt_auth/-/commits/master 12 | 13 | 14 | What? 15 | ----- 16 | 17 | This is an library designed to handle authentication in 18 | *server-to-server* API requests. It accomplishes this using RSA public / 19 | private key pairs. 20 | 21 | 22 | Why? 23 | ---- 24 | 25 | The standard pattern of using username and password works well for 26 | user-to-server requests, but is lacking for server-to-server 27 | applications. In these scenarios, since the password doesn’t need to be 28 | memorable by a user, we can use something far more secure: asymmetric 29 | key cryptography. This has the advantage that a password is never 30 | actually sent to the server. 31 | 32 | 33 | How? 34 | ---- 35 | 36 | A public / private key pair is generated by the client machine. The 37 | server machine is then supplied with the public key, which it can store 38 | in any method it likes. When this library is used with Django, it 39 | provides a model for storing public keys associated with built-in User 40 | objects. When a request is made, the client creates a JWT including 41 | several claims and signs it using it’s private key. Upon receipt, the 42 | server verifies the claim to using the public key to ensure the issuer 43 | is legitimately who they claim to be. 44 | 45 | The claim (issued by the client) includes components: the username of 46 | the user who is attempting authentication, the current unix timestamp, 47 | and a randomly generated nonce. For example: 48 | 49 | :: 50 | 51 | { 52 | "username": "guido", 53 | "time": 1439216312, 54 | "nonce": "1" 55 | } 56 | 57 | The timestamp must be within ±20 seconds of the server time and the 58 | nonce must be unique within the given timestamp and user. In other 59 | words, if more than one request from a user is made within the same 60 | second, the nonce must change. Due to these two factors no token is 61 | usable more than once, thereby preventing replay attacks. 62 | 63 | To make an authenticated request, the client must generate a JWT 64 | following the above format and include it as the HTTP Authorization 65 | header in the following format: 66 | 67 | :: 68 | 69 | Authorization: JWT 70 | 71 | **Important note**: the claim is *not* encrypted, only signed. 72 | Additionally, the signature only prevents the claim from being tampered 73 | with or re-used. Every other part of the request is still vulnerable to 74 | tamper. Therefore, this is not a replacement for using SSL in the 75 | transport layer. 76 | 77 | **Full Documentation**: https://asymmetric-jwt-auth.readthedocs.io 78 | -------------------------------------------------------------------------------- /bin/publish.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euxo pipefail 4 | 5 | # Check git status 6 | git fetch --all 7 | CURRENT_BRANCH=$(git branch --show-current) 8 | if [ "$CURRENT_BRANCH" != "master" ]; then 9 | echo "This script must be run only when the master branch is checked out, but the current branch is ${CURRENT_BRANCH}. Abort!" 10 | exit 1 11 | fi 12 | 13 | NUM_BEHIND=$(git log ..origin/master | wc -l | awk '{print $1}') 14 | if [ "$NUM_BEHIND" == "0" ]; then 15 | echo "" 16 | else 17 | echo "Your branch is NOT up to date with origin/master. Abort! Please fetch and rebase first." 18 | exit 1 19 | fi 20 | 21 | # Update version and publish via commitizen 22 | cz bump "$@" 23 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = asymmetric-jwt-auth 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API 2 | === 3 | 4 | 5 | Keys 6 | ---- 7 | 8 | .. module:: asymmetric_jwt_auth.keys 9 | .. autoclass:: PublicKey 10 | :members: 11 | .. autoclass:: RSAPublicKey 12 | :members: 13 | .. autoclass:: Ed25519PublicKey 14 | :members: 15 | .. autoclass:: PrivateKey 16 | :members: 17 | .. autoclass:: RSAPrivateKey 18 | :members: 19 | .. autoclass:: Ed25519PrivateKey 20 | :members: 21 | 22 | 23 | Middleware 24 | ---------- 25 | 26 | .. module:: asymmetric_jwt_auth.middleware 27 | .. autoclass:: JWTAuthMiddleware 28 | :members: 29 | 30 | 31 | Models 32 | ------ 33 | 34 | .. module:: asymmetric_jwt_auth.models 35 | .. autoclass:: PublicKey 36 | :members: 37 | .. autoclass:: JWKSEndpointTrust 38 | :members: 39 | 40 | 41 | Tokens 42 | ------ 43 | 44 | .. module:: asymmetric_jwt_auth.tokens 45 | .. autoclass:: Token 46 | :members: 47 | .. autoclass:: UntrustedToken 48 | :members: 49 | 50 | 51 | Nonces 52 | ------ 53 | 54 | .. module:: asymmetric_jwt_auth.nonce.base 55 | .. autoclass:: BaseNonceBackend 56 | :members: 57 | 58 | .. module:: asymmetric_jwt_auth.nonce.django 59 | .. autoclass:: DjangoCacheNonceBackend 60 | :members: 61 | 62 | .. module:: asymmetric_jwt_auth.nonce.null 63 | .. autoclass:: NullNonceBackend 64 | :members: 65 | 66 | 67 | Model Repositories 68 | ------------------ 69 | 70 | .. module:: asymmetric_jwt_auth.repos.base 71 | .. autoclass:: BaseUserRepository 72 | :members: 73 | .. autoclass:: BasePublicKeyRepository 74 | :members: 75 | 76 | .. module:: asymmetric_jwt_auth.repos.django 77 | .. autoclass:: DjangoUserRepository 78 | :members: 79 | .. autoclass:: DjangoPublicKeyListRepository 80 | :members: 81 | .. autoclass:: DjangoJWKSRepository 82 | :members: 83 | -------------------------------------------------------------------------------- /docs/changes.rst: -------------------------------------------------------------------------------- 1 | Change Log 2 | ========== 3 | 4 | 1.0.0 5 | ----- 6 | - Updated cryptography dependency to ``>=3.4.6``. 7 | - Updated PyJWT dependency to ``>=2.0.1``. 8 | - Added support for EdDSA signing and verification. 9 | - Added support for obtaining public keys via JWKS endpoints. 10 | - Refactored many things into classes to be more extensible. 11 | 12 | 0.5.0 13 | ----- 14 | - Add new ``PublicKey.last_used_on`` field 15 | 16 | 0.4.3 17 | ----- 18 | - Fix exception thrown by middleware when processing a request with a malformed Authorization header. 19 | 20 | 0.4.2 21 | ----- 22 | - Fix performance of Django Admin view when adding/changing a public key on a site with many users. 23 | 24 | 0.4.1 25 | ----- 26 | - Fix middleware in Django 2.0. 27 | 28 | 0.4.0 29 | ----- 30 | - Add support for Django 2.0. 31 | - Drop support for Django 1.8, 1.9, and 1.10. 32 | 33 | 0.3.1 34 | ----- 35 | - Made logging quieter by reducing severity of unimportant messages 36 | 37 | 38 | 0.3.0 39 | ----- 40 | - Improve `documentation `_. 41 | - Drop support for Python 3.3. 42 | - Upgrade dependency versions. 43 | 44 | 45 | 0.2.4 46 | ----- 47 | - Use setuptools instead of distutils 48 | 49 | 50 | 0.2.3 51 | ----- 52 | - Support swappable user models instead of being hard-tied to ``django.contrib.auth.models.User``. 53 | 54 | 55 | 0.2.2 56 | ----- 57 | - Fix README codec issue 58 | 59 | 60 | 0.2.1 61 | ----- 62 | - Allow PEM format keys through validation 63 | 64 | 65 | 0.2.0 66 | ----- 67 | - Validate a public keys before saving the model in the Django Admin interface. 68 | - Add comment field for describing a key 69 | - Make Public Keys separate from User in the Django Admin. 70 | - Change key reference from User to settings.AUTH_USER_MODEL 71 | - Adds test for get_claimed_username 72 | 73 | 74 | 0.1.7 75 | ----- 76 | - Fix bug in token.get_claimed_username 77 | 78 | 79 | 0.1.6 80 | ----- 81 | - Include migrations in build 82 | 83 | 84 | 0.1.5 85 | ----- 86 | - Add initial db migrations 87 | 88 | 89 | 0.1.4 90 | ----- 91 | - Fix Python3 bug in middleware 92 | - Drop support for Python 2.6 and Python 3.2 93 | - Add TravisCI builds 94 | 95 | 96 | 0.1.3 97 | ----- 98 | - Expand test coverage 99 | - Fix PyPi README formatting 100 | - Fix Python 3 compatibility 101 | - Add GitlabCI builds 102 | 103 | 104 | 0.1.2 105 | ----- 106 | - Fix bug in setting the authenticated user in the Django session 107 | - Fix bug in public key iteration 108 | 109 | 110 | 0.1.1 111 | ----- 112 | - Fix packaging bugs. 113 | 114 | 115 | 0.1.0 116 | ----- 117 | - Initial Release 118 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # asymmetric-jwt-auth documentation build configuration file, created by 4 | # sphinx-quickstart on Mon Jan 23 11:21:47 2017. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | import os 20 | import sys 21 | 22 | sys.path.insert(0, os.path.abspath("../")) 23 | sys.path.insert(0, os.path.abspath("../src/")) 24 | sys.path.insert(0, os.path.abspath("../sandbox/")) 25 | 26 | os.environ["DJANGO_SETTINGS_MODULE"] = "sandbox.settings" 27 | import django # NOQA 28 | 29 | django.setup() 30 | 31 | 32 | # -- General configuration ------------------------------------------------ 33 | 34 | # If your documentation needs a minimal Sphinx version, state it here. 35 | # 36 | # needs_sphinx = '1.0' 37 | 38 | # Add any Sphinx extension module names here, as strings. They can be 39 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 40 | # ones. 41 | extensions = [ 42 | "sphinx.ext.autodoc", 43 | "sphinx.ext.coverage", 44 | "sphinx.ext.viewcode", 45 | ] 46 | 47 | # Add any paths that contain templates here, relative to this directory. 48 | templates_path = ["_templates"] 49 | 50 | # The suffix(es) of source filenames. 51 | # You can specify multiple suffix as a list of string: 52 | # 53 | # source_suffix = ['.rst', '.md'] 54 | source_suffix = ".rst" 55 | 56 | # The master toctree document. 57 | master_doc = "index" 58 | 59 | # General information about the project. 60 | project = "asymmetric-jwt-auth" 61 | copyright = "2021, Craig Weber " 62 | author = "Craig Weber " 63 | 64 | # The version info for the project you're documenting, acts as replacement for 65 | # |version| and |release|, also used in various other places throughout the 66 | # built documents. 67 | # 68 | # The short X.Y version. 69 | from versiontag import get_version # NOQA 70 | 71 | version = get_version(pypi=True) 72 | # The full version, including alpha/beta/rc tags. 73 | release = get_version(pypi=True) 74 | 75 | # The language for content autogenerated by Sphinx. Refer to documentation 76 | # for a list of supported languages. 77 | # 78 | # This is also used if you do content translation via gettext catalogs. 79 | # Usually you set "language" from the command line for these cases. 80 | language = None 81 | 82 | # List of patterns, relative to source directory, that match files and 83 | # directories to ignore when looking for source files. 84 | # This patterns also effect to html_static_path and html_extra_path 85 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 86 | 87 | # The name of the Pygments (syntax highlighting) style to use. 88 | pygments_style = "sphinx" 89 | 90 | # If true, `todo` and `todoList` produce output, else they produce nothing. 91 | todo_include_todos = False 92 | 93 | 94 | # -- Options for HTML output ---------------------------------------------- 95 | 96 | # The theme to use for HTML and HTML Help pages. See the documentation for 97 | # a list of builtin themes. 98 | # 99 | import sphinx_rtd_theme # NOQA 100 | 101 | html_theme = "sphinx_rtd_theme" 102 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 103 | 104 | # Theme options are theme-specific and customize the look and feel of a theme 105 | # further. For a list of options available for each theme, see the 106 | # documentation. 107 | # 108 | # html_theme_options = {} 109 | 110 | # Add any paths that contain custom static files (such as style sheets) here, 111 | # relative to this directory. They are copied after the builtin static files, 112 | # so a file named "default.css" will overwrite the builtin "default.css". 113 | html_static_path = ["_static"] 114 | 115 | 116 | # -- Options for HTMLHelp output ------------------------------------------ 117 | 118 | # Output file base name for HTML help builder. 119 | htmlhelp_basename = "asymmetric-jwt-authdoc" 120 | 121 | 122 | # -- Options for LaTeX output --------------------------------------------- 123 | 124 | latex_elements = { 125 | # The paper size ('letterpaper' or 'a4paper'). 126 | # 127 | # 'papersize': 'letterpaper', 128 | # The font size ('10pt', '11pt' or '12pt'). 129 | # 130 | # 'pointsize': '10pt', 131 | # Additional stuff for the LaTeX preamble. 132 | # 133 | # 'preamble': '', 134 | # Latex figure (float) alignment 135 | # 136 | # 'figure_align': 'htbp', 137 | } 138 | 139 | # Grouping the document tree into LaTeX files. List of tuples 140 | # (source start file, target name, title, 141 | # author, documentclass [howto, manual, or own class]). 142 | latex_documents = [ 143 | ( 144 | master_doc, 145 | "asymmetric-jwt-auth.tex", 146 | "asymmetric-jwt-auth Documentation", 147 | "Craig Weber", 148 | "manual", 149 | ), 150 | ] 151 | 152 | 153 | # -- Options for manual page output --------------------------------------- 154 | 155 | # One entry per manual page. List of tuples 156 | # (source start file, name, description, authors, manual section). 157 | man_pages = [ 158 | ( 159 | master_doc, 160 | "asymmetric-jwt-auth", 161 | "asymmetric-jwt-auth Documentation", 162 | [author], 163 | 1, 164 | ) 165 | ] 166 | 167 | 168 | # -- Options for Texinfo output ------------------------------------------- 169 | 170 | # Grouping the document tree into Texinfo files. List of tuples 171 | # (source start file, target name, title, author, 172 | # dir menu entry, description, category) 173 | texinfo_documents = [ 174 | ( 175 | master_doc, 176 | "asymmetric-jwt-auth", 177 | "asymmetric-jwt-auth Documentation", 178 | author, 179 | "asymmetric-jwt-auth", 180 | "One line description of project.", 181 | "Miscellaneous", 182 | ), 183 | ] 184 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | 3 | 4 | Contents 5 | -------- 6 | 7 | .. toctree:: 8 | :maxdepth: 3 9 | 10 | install 11 | usage 12 | api 13 | changes 14 | license 15 | -------------------------------------------------------------------------------- /docs/install.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Dependencies 5 | ------------ 6 | 7 | We don't re-implement JWT or RSA in this library. Instead we rely on the widely used `PyJWT `_ and `cryptography `_ libraries as building blocks.. This library serves as a simple drop-in wrapper around those components. 8 | 9 | 10 | Django Server 11 | ------------- 12 | 13 | Install the library using pip. 14 | 15 | .. code:: bash 16 | 17 | pip install asymmetric_jwt_auth 18 | 19 | Add ``asymmetric_jwt_auth`` to the list of ``INSTALLED_APPS`` in ``settings.py`` 20 | 21 | .. code:: python 22 | 23 | INSTALLED_APPS = ( 24 | … 25 | 'asymmetric_jwt_auth', 26 | … 27 | ) 28 | 29 | Add ``asymmetric_jwt_auth.middleware.JWTAuthMiddleware`` to the list of ``MIDDLEWARE_CLASSES`` in ``settings.py`` 30 | 31 | .. code:: python 32 | 33 | MIDDLEWARE_CLASSES = ( 34 | … 35 | 'asymmetric_jwt_auth.middleware.JWTAuthMiddleware', 36 | ) 37 | 38 | Create the new models in your DB. 39 | 40 | .. code:: bash 41 | 42 | python manage.py migrate 43 | 44 | This creates a new relationship on the ``django.contrib.auth.models.User`` model. ``User`` now contains a one-to-many relationship to ``asymmetric_jwt_auth.models.PublicKey``. Any number of public key’s can be added to a user using the Django Admin site. 45 | 46 | The middleware activated above will watch for incoming requests with a JWT authorization header and will attempt to authenticate it using saved public keys. 47 | -------------------------------------------------------------------------------- /docs/license.rst: -------------------------------------------------------------------------------- 1 | License 2 | ======= 3 | 4 | .. include:: ../LICENSE.md 5 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | Usage 2 | ===== 3 | 4 | Unencrypted Private Key File 5 | ---------------------------- 6 | 7 | Here’s an example of making a request to a server using a JWT authentication header and the `requests`_ HTTP client library. 8 | 9 | .. code:: python 10 | 11 | from asymmetric_jwt_auth.keys import PrivateKey 12 | from asymmetric_jwt_auth.tokens import Token 13 | import requests 14 | 15 | # Load an RSA private key from file 16 | privkey = PrivateKey.load_pem_from_file('~/.ssh/id_rsa') 17 | # This is the user to authenticate as on the server 18 | auth = Token(username='crgwbr').create_auth_header(privkey) 19 | 20 | r = requests.get('http://example.com/api/endpoint/', headers={ 21 | 'Authorization': auth, 22 | }) 23 | 24 | 25 | Encrypted Private Key File 26 | -------------------------- 27 | 28 | This method also supports using an encrypted private key. 29 | 30 | .. code:: python 31 | 32 | from asymmetric_jwt_auth.keys import PrivateKey 33 | from asymmetric_jwt_auth.tokens import Token 34 | import requests 35 | 36 | # Load an RSA private key from file 37 | privkey = PrivateKey.load_pem_from_file('~/.ssh/id_rsa', 38 | password='somepassphrase') 39 | # This is the user to authenticate as on the server 40 | auth = Token(username='crgwbr').create_auth_header(privkey) 41 | 42 | r = requests.get('http://example.com/api/endpoint/', headers={ 43 | 'Authorization': auth 44 | }) 45 | 46 | 47 | Private Key File String 48 | ----------------------- 49 | 50 | If already you have the public key as a string, you can work directly with that instead of using a key file. 51 | 52 | .. code:: python 53 | 54 | from asymmetric_jwt_auth.keys import PrivateKey 55 | from asymmetric_jwt_auth.tokens import Token 56 | import requests 57 | 58 | MY_KEY = """-----BEGIN PRIVATE KEY----- 59 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCh3FtGHks62gHd 60 | KF/oreZGfswTsOijlCbmHvYhO34TpTSXqpcZ1UFOPReFBU2caOdlMbNTshpwjDVr 61 | /TepUcl9xzQqLuKDthI8wyXRZKnSbTzRiWwJn72D5YboOuCOkZBTvJoGE2wq1HkM 62 | /bRubzjXVL1UupXYYQ7MEqkHXT+XCFFm6/9CPuhvBKp1ULMw1vu3kseobQzE4XsF 63 | 5gQtcipMQoV9aRnK1cICeYL2GT1G3NRn+WvIPVSAIdnXqA+2Y90VXt+43wUE2ttp 64 | AKV3PpXodUOOw9XE+ZVizBXyoicbyQlSmbjyz08BZ+CLgcIaYmCf4itt53a2VF/v 65 | ePHIKBfRAgMBAAECggEBAIUeIGbzhTWalEvZ578KPkeeAqLzLPFTaAZ8UjqUniT0 66 | CuPtZaXWUIZTEiPRb7oCQMRl8rET2lDTzx/IOl3jqM3r5ggHVT2zoR4d9N1YZ55r 67 | Psipt5PWr1tpiuE1gvdd2hA0HYx/rscuxXucsCbfDCV0SN4FMjWp5SyK8D7hPuor 68 | ms6EJ+JgNWGJvVKbnBXrtfZtBaTW4BuIu8f2WxuHG3ngQl4jRR8Jnh5JniMROxy8 69 | MMx3/NmiU3hfhnhU2l1tQTn1t9cvciOF+DrZjdv30h1NPbexL+UczXFWb2aAYMtC 70 | 89iNadfqPdMIZF86Xg1dgLaYGOUa7K1xSCuspvUI2lECgYEA1tV9fwSgNcWqBwS5 71 | TisaqErVohBGqWB+74NOq6SfV9zM226QtrrU8yNlAhxQfwjDtqnAon3NtvZENula 72 | dsev99JLjtJFfV7jsqgz/ybEJ3tkEM/EiQU+eGfp58Dq3WpZb7a2PA/hDnRXsJDp 73 | w7dq/fTzkAmlG02CxpVDCc9R2m0CgYEAwOBPD6+zYQCguXxk/3COQBVpjtFzouqZ 74 | v5Oy3WVxSw/KCRO7/hMVCAAWI9JCTd3a44m8F8e03UoXs4u1eR49H5OufLilT+lf 75 | ImdbAvQMHb5cLPr4oh884ANfJih71xTmJnAJ8stX+HSGkKxs9yxVYoZWTGi/mw6z 76 | FttOYzAx1HUCgYBR9GWIlBIuETbYsJOkX0svEkVHKuBZ8wbZhgT387gZw5Ce0SIB 77 | o2pjSohY8sY+f/BxeXaURlu4xV+mdwTctTbK2n2agVqjBhTk7cfQOVCxIyA8TZZT 78 | Ex4Ovs17bJvsVYrC1DfW19PqOLXPFKko0YrOUKittRA4RyxxZzWIw38dTQKBgCEu 79 | tgth0/+NRxmCQDH+IEsAJA/xEu7lY5wlAfG7ARnD1qNnJMGacNTWhviUtNmGoKDi 80 | 0lxY/FHR7G/0Sj1TKXrkQnGspqwv3zEhDPReHjODy4Hlj578ttFnYxhCgMPJEatt 81 | PRjrSPAyw+/h6kE//FSd/fzZTJWVmtQE2OCRqxD9AoGASiN9htvqvXldVDMoR2F2 82 | F+KRA2lXYg78Rg+dpDYLJBk6t8c9e7/xLJATgZy3tLC5YQcpCkrfoCcztdmOiiVt 83 | Q55GCaDNUu1Ttwlu/6yocwYPPS4pP2/qUUDzzBoCEg+PfXSOAsLrGHQ3YLoqbw/H 84 | DxwoXAVLIrFyhFJdklMTnZs= 85 | -----END PRIVATE KEY----- 86 | """ 87 | 88 | privkey = PrivateKey.load_pem(MY_KEY.encode()) 89 | auth = Token(username='crgwbr').create_auth_header(privkey) 90 | 91 | r = requests.get('http://example.com/api/endpoint/', headers={ 92 | 'Authorization': auth 93 | }) 94 | 95 | .. _requests: http://www.python-requests.org/ 96 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "alabaster" 5 | version = "1.0.0" 6 | description = "A light, configurable Sphinx theme" 7 | optional = false 8 | python-versions = ">=3.10" 9 | groups = ["dev"] 10 | files = [ 11 | {file = "alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b"}, 12 | {file = "alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e"}, 13 | ] 14 | 15 | [[package]] 16 | name = "asgiref" 17 | version = "3.8.1" 18 | description = "ASGI specs, helper code, and adapters" 19 | optional = false 20 | python-versions = ">=3.8" 21 | groups = ["main", "dev"] 22 | files = [ 23 | {file = "asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47"}, 24 | {file = "asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590"}, 25 | ] 26 | 27 | [package.extras] 28 | tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] 29 | 30 | [[package]] 31 | name = "babel" 32 | version = "2.17.0" 33 | description = "Internationalization utilities" 34 | optional = false 35 | python-versions = ">=3.8" 36 | groups = ["dev"] 37 | files = [ 38 | {file = "babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2"}, 39 | {file = "babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d"}, 40 | ] 41 | 42 | [package.extras] 43 | dev = ["backports.zoneinfo", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata"] 44 | 45 | [[package]] 46 | name = "certifi" 47 | version = "2025.4.26" 48 | description = "Python package for providing Mozilla's CA Bundle." 49 | optional = false 50 | python-versions = ">=3.6" 51 | groups = ["dev"] 52 | files = [ 53 | {file = "certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3"}, 54 | {file = "certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6"}, 55 | ] 56 | 57 | [[package]] 58 | name = "cffi" 59 | version = "1.17.1" 60 | description = "Foreign Function Interface for Python calling C code." 61 | optional = false 62 | python-versions = ">=3.8" 63 | groups = ["main"] 64 | markers = "platform_python_implementation != \"PyPy\"" 65 | files = [ 66 | {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, 67 | {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, 68 | {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, 69 | {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, 70 | {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, 71 | {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, 72 | {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, 73 | {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, 74 | {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, 75 | {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, 76 | {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, 77 | {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, 78 | {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, 79 | {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, 80 | {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, 81 | {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, 82 | {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, 83 | {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, 84 | {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, 85 | {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, 86 | {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, 87 | {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, 88 | {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, 89 | {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, 90 | {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, 91 | {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, 92 | {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, 93 | {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, 94 | {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, 95 | {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, 96 | {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, 97 | {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, 98 | {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, 99 | {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, 100 | {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, 101 | {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, 102 | {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, 103 | {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, 104 | {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, 105 | {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, 106 | {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, 107 | {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, 108 | {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, 109 | {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, 110 | {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, 111 | {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, 112 | {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, 113 | {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, 114 | {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, 115 | {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, 116 | {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, 117 | {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, 118 | {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, 119 | {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, 120 | {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, 121 | {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, 122 | {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, 123 | {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, 124 | {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, 125 | {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, 126 | {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, 127 | {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, 128 | {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, 129 | {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, 130 | {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, 131 | {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, 132 | {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, 133 | ] 134 | 135 | [package.dependencies] 136 | pycparser = "*" 137 | 138 | [[package]] 139 | name = "charset-normalizer" 140 | version = "3.4.2" 141 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 142 | optional = false 143 | python-versions = ">=3.7" 144 | groups = ["dev"] 145 | files = [ 146 | {file = "charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941"}, 147 | {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd"}, 148 | {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6"}, 149 | {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d"}, 150 | {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86"}, 151 | {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c"}, 152 | {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0"}, 153 | {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef"}, 154 | {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6"}, 155 | {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366"}, 156 | {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db"}, 157 | {file = "charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a"}, 158 | {file = "charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509"}, 159 | {file = "charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2"}, 160 | {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645"}, 161 | {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd"}, 162 | {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8"}, 163 | {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f"}, 164 | {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7"}, 165 | {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9"}, 166 | {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544"}, 167 | {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82"}, 168 | {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0"}, 169 | {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5"}, 170 | {file = "charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a"}, 171 | {file = "charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28"}, 172 | {file = "charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7"}, 173 | {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3"}, 174 | {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a"}, 175 | {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214"}, 176 | {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a"}, 177 | {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd"}, 178 | {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981"}, 179 | {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c"}, 180 | {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b"}, 181 | {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d"}, 182 | {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f"}, 183 | {file = "charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c"}, 184 | {file = "charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e"}, 185 | {file = "charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0"}, 186 | {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf"}, 187 | {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e"}, 188 | {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1"}, 189 | {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c"}, 190 | {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691"}, 191 | {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0"}, 192 | {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b"}, 193 | {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff"}, 194 | {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b"}, 195 | {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148"}, 196 | {file = "charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7"}, 197 | {file = "charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980"}, 198 | {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184"}, 199 | {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa"}, 200 | {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344"}, 201 | {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da"}, 202 | {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02"}, 203 | {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d"}, 204 | {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4"}, 205 | {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f"}, 206 | {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64"}, 207 | {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f"}, 208 | {file = "charset_normalizer-3.4.2-cp37-cp37m-win32.whl", hash = "sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58"}, 209 | {file = "charset_normalizer-3.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2"}, 210 | {file = "charset_normalizer-3.4.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb"}, 211 | {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a"}, 212 | {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45"}, 213 | {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5"}, 214 | {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1"}, 215 | {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027"}, 216 | {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b"}, 217 | {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455"}, 218 | {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01"}, 219 | {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58"}, 220 | {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681"}, 221 | {file = "charset_normalizer-3.4.2-cp38-cp38-win32.whl", hash = "sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7"}, 222 | {file = "charset_normalizer-3.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a"}, 223 | {file = "charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4"}, 224 | {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7"}, 225 | {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836"}, 226 | {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597"}, 227 | {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7"}, 228 | {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f"}, 229 | {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba"}, 230 | {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12"}, 231 | {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518"}, 232 | {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5"}, 233 | {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3"}, 234 | {file = "charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471"}, 235 | {file = "charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e"}, 236 | {file = "charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0"}, 237 | {file = "charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63"}, 238 | ] 239 | 240 | [[package]] 241 | name = "colorama" 242 | version = "0.4.6" 243 | description = "Cross-platform colored terminal text." 244 | optional = false 245 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 246 | groups = ["dev"] 247 | markers = "sys_platform == \"win32\"" 248 | files = [ 249 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 250 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 251 | ] 252 | 253 | [[package]] 254 | name = "coverage" 255 | version = "7.8.2" 256 | description = "Code coverage measurement for Python" 257 | optional = false 258 | python-versions = ">=3.9" 259 | groups = ["dev"] 260 | files = [ 261 | {file = "coverage-7.8.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bd8ec21e1443fd7a447881332f7ce9d35b8fbd2849e761bb290b584535636b0a"}, 262 | {file = "coverage-7.8.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4c26c2396674816deaeae7ded0e2b42c26537280f8fe313335858ffff35019be"}, 263 | {file = "coverage-7.8.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1aec326ed237e5880bfe69ad41616d333712c7937bcefc1343145e972938f9b3"}, 264 | {file = "coverage-7.8.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5e818796f71702d7a13e50c70de2a1924f729228580bcba1607cccf32eea46e6"}, 265 | {file = "coverage-7.8.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:546e537d9e24efc765c9c891328f30f826e3e4808e31f5d0f87c4ba12bbd1622"}, 266 | {file = "coverage-7.8.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ab9b09a2349f58e73f8ebc06fac546dd623e23b063e5398343c5270072e3201c"}, 267 | {file = "coverage-7.8.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fd51355ab8a372d89fb0e6a31719e825cf8df8b6724bee942fb5b92c3f016ba3"}, 268 | {file = "coverage-7.8.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0774df1e093acb6c9e4d58bce7f86656aeed6c132a16e2337692c12786b32404"}, 269 | {file = "coverage-7.8.2-cp310-cp310-win32.whl", hash = "sha256:00f2e2f2e37f47e5f54423aeefd6c32a7dbcedc033fcd3928a4f4948e8b96af7"}, 270 | {file = "coverage-7.8.2-cp310-cp310-win_amd64.whl", hash = "sha256:145b07bea229821d51811bf15eeab346c236d523838eda395ea969d120d13347"}, 271 | {file = "coverage-7.8.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b99058eef42e6a8dcd135afb068b3d53aff3921ce699e127602efff9956457a9"}, 272 | {file = "coverage-7.8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5feb7f2c3e6ea94d3b877def0270dff0947b8d8c04cfa34a17be0a4dc1836879"}, 273 | {file = "coverage-7.8.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:670a13249b957bb9050fab12d86acef7bf8f6a879b9d1a883799276e0d4c674a"}, 274 | {file = "coverage-7.8.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0bdc8bf760459a4a4187b452213e04d039990211f98644c7292adf1e471162b5"}, 275 | {file = "coverage-7.8.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07a989c867986c2a75f158f03fdb413128aad29aca9d4dbce5fc755672d96f11"}, 276 | {file = "coverage-7.8.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2db10dedeb619a771ef0e2949ccba7b75e33905de959c2643a4607bef2f3fb3a"}, 277 | {file = "coverage-7.8.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e6ea7dba4e92926b7b5f0990634b78ea02f208d04af520c73a7c876d5a8d36cb"}, 278 | {file = "coverage-7.8.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ef2f22795a7aca99fc3c84393a55a53dd18ab8c93fb431004e4d8f0774150f54"}, 279 | {file = "coverage-7.8.2-cp311-cp311-win32.whl", hash = "sha256:641988828bc18a6368fe72355df5f1703e44411adbe49bba5644b941ce6f2e3a"}, 280 | {file = "coverage-7.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:8ab4a51cb39dc1933ba627e0875046d150e88478dbe22ce145a68393e9652975"}, 281 | {file = "coverage-7.8.2-cp311-cp311-win_arm64.whl", hash = "sha256:8966a821e2083c74d88cca5b7dcccc0a3a888a596a04c0b9668a891de3a0cc53"}, 282 | {file = "coverage-7.8.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e2f6fe3654468d061942591aef56686131335b7a8325684eda85dacdf311356c"}, 283 | {file = "coverage-7.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76090fab50610798cc05241bf83b603477c40ee87acd358b66196ab0ca44ffa1"}, 284 | {file = "coverage-7.8.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bd0a0a5054be160777a7920b731a0570284db5142abaaf81bcbb282b8d99279"}, 285 | {file = "coverage-7.8.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da23ce9a3d356d0affe9c7036030b5c8f14556bd970c9b224f9c8205505e3b99"}, 286 | {file = "coverage-7.8.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9392773cffeb8d7e042a7b15b82a414011e9d2b5fdbbd3f7e6a6b17d5e21b20"}, 287 | {file = "coverage-7.8.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:876cbfd0b09ce09d81585d266c07a32657beb3eaec896f39484b631555be0fe2"}, 288 | {file = "coverage-7.8.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3da9b771c98977a13fbc3830f6caa85cae6c9c83911d24cb2d218e9394259c57"}, 289 | {file = "coverage-7.8.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a990f6510b3292686713bfef26d0049cd63b9c7bb17e0864f133cbfd2e6167f"}, 290 | {file = "coverage-7.8.2-cp312-cp312-win32.whl", hash = "sha256:bf8111cddd0f2b54d34e96613e7fbdd59a673f0cf5574b61134ae75b6f5a33b8"}, 291 | {file = "coverage-7.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:86a323a275e9e44cdf228af9b71c5030861d4d2610886ab920d9945672a81223"}, 292 | {file = "coverage-7.8.2-cp312-cp312-win_arm64.whl", hash = "sha256:820157de3a589e992689ffcda8639fbabb313b323d26388d02e154164c57b07f"}, 293 | {file = "coverage-7.8.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ea561010914ec1c26ab4188aef8b1567272ef6de096312716f90e5baa79ef8ca"}, 294 | {file = "coverage-7.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cb86337a4fcdd0e598ff2caeb513ac604d2f3da6d53df2c8e368e07ee38e277d"}, 295 | {file = "coverage-7.8.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a4636ddb666971345541b59899e969f3b301143dd86b0ddbb570bd591f1e85"}, 296 | {file = "coverage-7.8.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5040536cf9b13fb033f76bcb5e1e5cb3b57c4807fef37db9e0ed129c6a094257"}, 297 | {file = "coverage-7.8.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc67994df9bcd7e0150a47ef41278b9e0a0ea187caba72414b71dc590b99a108"}, 298 | {file = "coverage-7.8.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e6c86888fd076d9e0fe848af0a2142bf606044dc5ceee0aa9eddb56e26895a0"}, 299 | {file = "coverage-7.8.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:684ca9f58119b8e26bef860db33524ae0365601492e86ba0b71d513f525e7050"}, 300 | {file = "coverage-7.8.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8165584ddedb49204c4e18da083913bdf6a982bfb558632a79bdaadcdafd0d48"}, 301 | {file = "coverage-7.8.2-cp313-cp313-win32.whl", hash = "sha256:34759ee2c65362163699cc917bdb2a54114dd06d19bab860725f94ef45a3d9b7"}, 302 | {file = "coverage-7.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:2f9bc608fbafaee40eb60a9a53dbfb90f53cc66d3d32c2849dc27cf5638a21e3"}, 303 | {file = "coverage-7.8.2-cp313-cp313-win_arm64.whl", hash = "sha256:9fe449ee461a3b0c7105690419d0b0aba1232f4ff6d120a9e241e58a556733f7"}, 304 | {file = "coverage-7.8.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8369a7c8ef66bded2b6484053749ff220dbf83cba84f3398c84c51a6f748a008"}, 305 | {file = "coverage-7.8.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:159b81df53a5fcbc7d45dae3adad554fdbde9829a994e15227b3f9d816d00b36"}, 306 | {file = "coverage-7.8.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6fcbbd35a96192d042c691c9e0c49ef54bd7ed865846a3c9d624c30bb67ce46"}, 307 | {file = "coverage-7.8.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05364b9cc82f138cc86128dc4e2e1251c2981a2218bfcd556fe6b0fbaa3501be"}, 308 | {file = "coverage-7.8.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46d532db4e5ff3979ce47d18e2fe8ecad283eeb7367726da0e5ef88e4fe64740"}, 309 | {file = "coverage-7.8.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4000a31c34932e7e4fa0381a3d6deb43dc0c8f458e3e7ea6502e6238e10be625"}, 310 | {file = "coverage-7.8.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:43ff5033d657cd51f83015c3b7a443287250dc14e69910577c3e03bd2e06f27b"}, 311 | {file = "coverage-7.8.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94316e13f0981cbbba132c1f9f365cac1d26716aaac130866ca812006f662199"}, 312 | {file = "coverage-7.8.2-cp313-cp313t-win32.whl", hash = "sha256:3f5673888d3676d0a745c3d0e16da338c5eea300cb1f4ada9c872981265e76d8"}, 313 | {file = "coverage-7.8.2-cp313-cp313t-win_amd64.whl", hash = "sha256:2c08b05ee8d7861e45dc5a2cc4195c8c66dca5ac613144eb6ebeaff2d502e73d"}, 314 | {file = "coverage-7.8.2-cp313-cp313t-win_arm64.whl", hash = "sha256:1e1448bb72b387755e1ff3ef1268a06617afd94188164960dba8d0245a46004b"}, 315 | {file = "coverage-7.8.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:496948261eaac5ac9cf43f5d0a9f6eb7a6d4cb3bedb2c5d294138142f5c18f2a"}, 316 | {file = "coverage-7.8.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:eacd2de0d30871eff893bab0b67840a96445edcb3c8fd915e6b11ac4b2f3fa6d"}, 317 | {file = "coverage-7.8.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b039ffddc99ad65d5078ef300e0c7eed08c270dc26570440e3ef18beb816c1ca"}, 318 | {file = "coverage-7.8.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e49824808d4375ede9dd84e9961a59c47f9113039f1a525e6be170aa4f5c34d"}, 319 | {file = "coverage-7.8.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b069938961dfad881dc2f8d02b47645cd2f455d3809ba92a8a687bf513839787"}, 320 | {file = "coverage-7.8.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:de77c3ba8bb686d1c411e78ee1b97e6e0b963fb98b1637658dd9ad2c875cf9d7"}, 321 | {file = "coverage-7.8.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1676628065a498943bd3f64f099bb573e08cf1bc6088bbe33cf4424e0876f4b3"}, 322 | {file = "coverage-7.8.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8e1a26e7e50076e35f7afafde570ca2b4d7900a491174ca357d29dece5aacee7"}, 323 | {file = "coverage-7.8.2-cp39-cp39-win32.whl", hash = "sha256:6782a12bf76fa61ad9350d5a6ef5f3f020b57f5e6305cbc663803f2ebd0f270a"}, 324 | {file = "coverage-7.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:1efa4166ba75ccefd647f2d78b64f53f14fb82622bc94c5a5cb0a622f50f1c9e"}, 325 | {file = "coverage-7.8.2-pp39.pp310.pp311-none-any.whl", hash = "sha256:ec455eedf3ba0bbdf8f5a570012617eb305c63cb9f03428d39bf544cb2b94837"}, 326 | {file = "coverage-7.8.2-py3-none-any.whl", hash = "sha256:726f32ee3713f7359696331a18daf0c3b3a70bb0ae71141b9d3c52be7c595e32"}, 327 | {file = "coverage-7.8.2.tar.gz", hash = "sha256:a886d531373a1f6ff9fad2a2ba4a045b68467b779ae729ee0b3b10ac20033b27"}, 328 | ] 329 | 330 | [package.extras] 331 | toml = ["tomli"] 332 | 333 | [[package]] 334 | name = "cryptography" 335 | version = "45.0.3" 336 | description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." 337 | optional = false 338 | python-versions = "!=3.9.0,!=3.9.1,>=3.7" 339 | groups = ["main"] 340 | files = [ 341 | {file = "cryptography-45.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:7573d9eebaeceeb55285205dbbb8753ac1e962af3d9640791d12b36864065e71"}, 342 | {file = "cryptography-45.0.3-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d377dde61c5d67eb4311eace661c3efda46c62113ff56bf05e2d679e02aebb5b"}, 343 | {file = "cryptography-45.0.3-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fae1e637f527750811588e4582988932c222f8251f7b7ea93739acb624e1487f"}, 344 | {file = "cryptography-45.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ca932e11218bcc9ef812aa497cdf669484870ecbcf2d99b765d6c27a86000942"}, 345 | {file = "cryptography-45.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af3f92b1dc25621f5fad065288a44ac790c5798e986a34d393ab27d2b27fcff9"}, 346 | {file = "cryptography-45.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2f8f8f0b73b885ddd7f3d8c2b2234a7d3ba49002b0223f58cfde1bedd9563c56"}, 347 | {file = "cryptography-45.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9cc80ce69032ffa528b5e16d217fa4d8d4bb7d6ba8659c1b4d74a1b0f4235fca"}, 348 | {file = "cryptography-45.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c824c9281cb628015bfc3c59335163d4ca0540d49de4582d6c2637312907e4b1"}, 349 | {file = "cryptography-45.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5833bb4355cb377ebd880457663a972cd044e7f49585aee39245c0d592904578"}, 350 | {file = "cryptography-45.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bb5bf55dcb69f7067d80354d0a348368da907345a2c448b0babc4215ccd3497"}, 351 | {file = "cryptography-45.0.3-cp311-abi3-win32.whl", hash = "sha256:3ad69eeb92a9de9421e1f6685e85a10fbcfb75c833b42cc9bc2ba9fb00da4710"}, 352 | {file = "cryptography-45.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:97787952246a77d77934d41b62fb1b6f3581d83f71b44796a4158d93b8f5c490"}, 353 | {file = "cryptography-45.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:c92519d242703b675ccefd0f0562eb45e74d438e001f8ab52d628e885751fb06"}, 354 | {file = "cryptography-45.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5edcb90da1843df85292ef3a313513766a78fbbb83f584a5a58fb001a5a9d57"}, 355 | {file = "cryptography-45.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38deed72285c7ed699864f964a3f4cf11ab3fb38e8d39cfcd96710cd2b5bb716"}, 356 | {file = "cryptography-45.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5555365a50efe1f486eed6ac7062c33b97ccef409f5970a0b6f205a7cfab59c8"}, 357 | {file = "cryptography-45.0.3-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e4253ed8f5948a3589b3caee7ad9a5bf218ffd16869c516535325fece163dcc"}, 358 | {file = "cryptography-45.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cfd84777b4b6684955ce86156cfb5e08d75e80dc2585e10d69e47f014f0a5342"}, 359 | {file = "cryptography-45.0.3-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:a2b56de3417fd5f48773ad8e91abaa700b678dc7fe1e0c757e1ae340779acf7b"}, 360 | {file = "cryptography-45.0.3-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:57a6500d459e8035e813bd8b51b671977fb149a8c95ed814989da682314d0782"}, 361 | {file = "cryptography-45.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f22af3c78abfbc7cbcdf2c55d23c3e022e1a462ee2481011d518c7fb9c9f3d65"}, 362 | {file = "cryptography-45.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:232954730c362638544758a8160c4ee1b832dc011d2c41a306ad8f7cccc5bb0b"}, 363 | {file = "cryptography-45.0.3-cp37-abi3-win32.whl", hash = "sha256:cb6ab89421bc90e0422aca911c69044c2912fc3debb19bb3c1bfe28ee3dff6ab"}, 364 | {file = "cryptography-45.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:d54ae41e6bd70ea23707843021c778f151ca258081586f0cfa31d936ae43d1b2"}, 365 | {file = "cryptography-45.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ed43d396f42028c1f47b5fec012e9e12631266e3825e95c00e3cf94d472dac49"}, 366 | {file = "cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fed5aaca1750e46db870874c9c273cd5182a9e9deb16f06f7bdffdb5c2bde4b9"}, 367 | {file = "cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:00094838ecc7c6594171e8c8a9166124c1197b074cfca23645cee573910d76bc"}, 368 | {file = "cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:92d5f428c1a0439b2040435a1d6bc1b26ebf0af88b093c3628913dd464d13fa1"}, 369 | {file = "cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:ec64ee375b5aaa354b2b273c921144a660a511f9df8785e6d1c942967106438e"}, 370 | {file = "cryptography-45.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:71320fbefd05454ef2d457c481ba9a5b0e540f3753354fff6f780927c25d19b0"}, 371 | {file = "cryptography-45.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:edd6d51869beb7f0d472e902ef231a9b7689508e83880ea16ca3311a00bf5ce7"}, 372 | {file = "cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:555e5e2d3a53b4fabeca32835878b2818b3f23966a4efb0d566689777c5a12c8"}, 373 | {file = "cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:25286aacb947286620a31f78f2ed1a32cded7be5d8b729ba3fb2c988457639e4"}, 374 | {file = "cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:050ce5209d5072472971e6efbfc8ec5a8f9a841de5a4db0ebd9c2e392cb81972"}, 375 | {file = "cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:dc10ec1e9f21f33420cc05214989544727e776286c1c16697178978327b95c9c"}, 376 | {file = "cryptography-45.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:9eda14f049d7f09c2e8fb411dda17dd6b16a3c76a1de5e249188a32aeb92de19"}, 377 | {file = "cryptography-45.0.3.tar.gz", hash = "sha256:ec21313dd335c51d7877baf2972569f40a4291b76a0ce51391523ae358d05899"}, 378 | ] 379 | 380 | [package.dependencies] 381 | cffi = {version = ">=1.14", markers = "platform_python_implementation != \"PyPy\""} 382 | 383 | [package.extras] 384 | docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] 385 | docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] 386 | nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2)"] 387 | pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] 388 | sdist = ["build (>=1.0.0)"] 389 | ssh = ["bcrypt (>=3.1.5)"] 390 | test = ["certifi (>=2024)", "cryptography-vectors (==45.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] 391 | test-randomorder = ["pytest-randomly"] 392 | 393 | [[package]] 394 | name = "django" 395 | version = "5.2.1" 396 | description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." 397 | optional = false 398 | python-versions = ">=3.10" 399 | groups = ["main", "dev"] 400 | files = [ 401 | {file = "django-5.2.1-py3-none-any.whl", hash = "sha256:a9b680e84f9a0e71da83e399f1e922e1ab37b2173ced046b541c72e1589a5961"}, 402 | {file = "django-5.2.1.tar.gz", hash = "sha256:57fe1f1b59462caed092c80b3dd324fd92161b620d59a9ba9181c34746c97284"}, 403 | ] 404 | 405 | [package.dependencies] 406 | asgiref = ">=3.8.1" 407 | sqlparse = ">=0.3.1" 408 | tzdata = {version = "*", markers = "sys_platform == \"win32\""} 409 | 410 | [package.extras] 411 | argon2 = ["argon2-cffi (>=19.1.0)"] 412 | bcrypt = ["bcrypt"] 413 | 414 | [[package]] 415 | name = "django-stubs" 416 | version = "5.2.0" 417 | description = "Mypy stubs for Django" 418 | optional = false 419 | python-versions = ">=3.10" 420 | groups = ["dev"] 421 | files = [ 422 | {file = "django_stubs-5.2.0-py3-none-any.whl", hash = "sha256:cd52da033489afc1357d6245f49e3cc57bf49015877253fb8efc6722ea3d2d2b"}, 423 | {file = "django_stubs-5.2.0.tar.gz", hash = "sha256:07e25c2d3cbff5be540227ff37719cc89f215dfaaaa5eb038a75b01bbfbb2722"}, 424 | ] 425 | 426 | [package.dependencies] 427 | asgiref = "*" 428 | django = "*" 429 | django-stubs-ext = ">=5.2.0" 430 | types-PyYAML = "*" 431 | typing-extensions = ">=4.11.0" 432 | 433 | [package.extras] 434 | compatible-mypy = ["mypy (>=1.13,<1.16)"] 435 | oracle = ["oracledb"] 436 | redis = ["redis"] 437 | 438 | [[package]] 439 | name = "django-stubs-ext" 440 | version = "5.2.0" 441 | description = "Monkey-patching and extensions for django-stubs" 442 | optional = false 443 | python-versions = ">=3.10" 444 | groups = ["dev"] 445 | files = [ 446 | {file = "django_stubs_ext-5.2.0-py3-none-any.whl", hash = "sha256:b27ae0aab970af4894ba4e9b3fcd3e03421dc8731516669659ee56122d148b23"}, 447 | {file = "django_stubs_ext-5.2.0.tar.gz", hash = "sha256:00c4ae307b538f5643af761a914c3f8e4e3f25f4e7c6d7098f1906c0d8f2aac9"}, 448 | ] 449 | 450 | [package.dependencies] 451 | django = "*" 452 | typing-extensions = "*" 453 | 454 | [[package]] 455 | name = "docutils" 456 | version = "0.21.2" 457 | description = "Docutils -- Python Documentation Utilities" 458 | optional = false 459 | python-versions = ">=3.9" 460 | groups = ["dev"] 461 | files = [ 462 | {file = "docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2"}, 463 | {file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"}, 464 | ] 465 | 466 | [[package]] 467 | name = "flake8" 468 | version = "7.2.0" 469 | description = "the modular source code checker: pep8 pyflakes and co" 470 | optional = false 471 | python-versions = ">=3.9" 472 | groups = ["dev"] 473 | files = [ 474 | {file = "flake8-7.2.0-py2.py3-none-any.whl", hash = "sha256:93b92ba5bdb60754a6da14fa3b93a9361fd00a59632ada61fd7b130436c40343"}, 475 | {file = "flake8-7.2.0.tar.gz", hash = "sha256:fa558ae3f6f7dbf2b4f22663e5343b6b6023620461f8d4ff2019ef4b5ee70426"}, 476 | ] 477 | 478 | [package.dependencies] 479 | mccabe = ">=0.7.0,<0.8.0" 480 | pycodestyle = ">=2.13.0,<2.14.0" 481 | pyflakes = ">=3.3.0,<3.4.0" 482 | 483 | [[package]] 484 | name = "freezegun" 485 | version = "1.5.2" 486 | description = "Let your Python tests travel through time" 487 | optional = false 488 | python-versions = ">=3.8" 489 | groups = ["dev"] 490 | files = [ 491 | {file = "freezegun-1.5.2-py3-none-any.whl", hash = "sha256:5aaf3ba229cda57afab5bd311f0108d86b6fb119ae89d2cd9c43ec8c1733c85b"}, 492 | {file = "freezegun-1.5.2.tar.gz", hash = "sha256:a54ae1d2f9c02dbf42e02c18a3ab95ab4295818b549a34dac55592d72a905181"}, 493 | ] 494 | 495 | [package.dependencies] 496 | python-dateutil = ">=2.7" 497 | 498 | [[package]] 499 | name = "idna" 500 | version = "3.10" 501 | description = "Internationalized Domain Names in Applications (IDNA)" 502 | optional = false 503 | python-versions = ">=3.6" 504 | groups = ["dev"] 505 | files = [ 506 | {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, 507 | {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, 508 | ] 509 | 510 | [package.extras] 511 | all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] 512 | 513 | [[package]] 514 | name = "imagesize" 515 | version = "1.4.1" 516 | description = "Getting image size from png/jpeg/jpeg2000/gif file" 517 | optional = false 518 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 519 | groups = ["dev"] 520 | files = [ 521 | {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, 522 | {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, 523 | ] 524 | 525 | [[package]] 526 | name = "jinja2" 527 | version = "3.1.6" 528 | description = "A very fast and expressive template engine." 529 | optional = false 530 | python-versions = ">=3.7" 531 | groups = ["dev"] 532 | files = [ 533 | {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, 534 | {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, 535 | ] 536 | 537 | [package.dependencies] 538 | MarkupSafe = ">=2.0" 539 | 540 | [package.extras] 541 | i18n = ["Babel (>=2.7)"] 542 | 543 | [[package]] 544 | name = "markupsafe" 545 | version = "3.0.2" 546 | description = "Safely add untrusted strings to HTML/XML markup." 547 | optional = false 548 | python-versions = ">=3.9" 549 | groups = ["dev"] 550 | files = [ 551 | {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, 552 | {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, 553 | {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, 554 | {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, 555 | {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, 556 | {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, 557 | {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, 558 | {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, 559 | {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, 560 | {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, 561 | {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, 562 | {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, 563 | {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, 564 | {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, 565 | {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, 566 | {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, 567 | {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, 568 | {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, 569 | {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, 570 | {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, 571 | {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, 572 | {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, 573 | {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, 574 | {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, 575 | {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, 576 | {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, 577 | {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, 578 | {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, 579 | {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, 580 | {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, 581 | {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, 582 | {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, 583 | {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, 584 | {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, 585 | {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, 586 | {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, 587 | {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, 588 | {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, 589 | {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, 590 | {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, 591 | {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, 592 | {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, 593 | {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, 594 | {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, 595 | {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, 596 | {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, 597 | {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, 598 | {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, 599 | {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, 600 | {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, 601 | {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, 602 | {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, 603 | {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, 604 | {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, 605 | {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, 606 | {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, 607 | {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, 608 | {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, 609 | {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, 610 | {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, 611 | {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, 612 | ] 613 | 614 | [[package]] 615 | name = "mccabe" 616 | version = "0.7.0" 617 | description = "McCabe checker, plugin for flake8" 618 | optional = false 619 | python-versions = ">=3.6" 620 | groups = ["dev"] 621 | files = [ 622 | {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, 623 | {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, 624 | ] 625 | 626 | [[package]] 627 | name = "mypy" 628 | version = "1.16.0" 629 | description = "Optional static typing for Python" 630 | optional = false 631 | python-versions = ">=3.9" 632 | groups = ["dev"] 633 | files = [ 634 | {file = "mypy-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7909541fef256527e5ee9c0a7e2aeed78b6cda72ba44298d1334fe7881b05c5c"}, 635 | {file = "mypy-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e71d6f0090c2256c713ed3d52711d01859c82608b5d68d4fa01a3fe30df95571"}, 636 | {file = "mypy-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:936ccfdd749af4766be824268bfe22d1db9eb2f34a3ea1d00ffbe5b5265f5491"}, 637 | {file = "mypy-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4086883a73166631307fdd330c4a9080ce24913d4f4c5ec596c601b3a4bdd777"}, 638 | {file = "mypy-1.16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:feec38097f71797da0231997e0de3a58108c51845399669ebc532c815f93866b"}, 639 | {file = "mypy-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:09a8da6a0ee9a9770b8ff61b39c0bb07971cda90e7297f4213741b48a0cc8d93"}, 640 | {file = "mypy-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9f826aaa7ff8443bac6a494cf743f591488ea940dd360e7dd330e30dd772a5ab"}, 641 | {file = "mypy-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:82d056e6faa508501af333a6af192c700b33e15865bda49611e3d7d8358ebea2"}, 642 | {file = "mypy-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:089bedc02307c2548eb51f426e085546db1fa7dd87fbb7c9fa561575cf6eb1ff"}, 643 | {file = "mypy-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6a2322896003ba66bbd1318c10d3afdfe24e78ef12ea10e2acd985e9d684a666"}, 644 | {file = "mypy-1.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:021a68568082c5b36e977d54e8f1de978baf401a33884ffcea09bd8e88a98f4c"}, 645 | {file = "mypy-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:54066fed302d83bf5128632d05b4ec68412e1f03ef2c300434057d66866cea4b"}, 646 | {file = "mypy-1.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c5436d11e89a3ad16ce8afe752f0f373ae9620841c50883dc96f8b8805620b13"}, 647 | {file = "mypy-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f2622af30bf01d8fc36466231bdd203d120d7a599a6d88fb22bdcb9dbff84090"}, 648 | {file = "mypy-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d045d33c284e10a038f5e29faca055b90eee87da3fc63b8889085744ebabb5a1"}, 649 | {file = "mypy-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4968f14f44c62e2ec4a038c8797a87315be8df7740dc3ee8d3bfe1c6bf5dba8"}, 650 | {file = "mypy-1.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eb14a4a871bb8efb1e4a50360d4e3c8d6c601e7a31028a2c79f9bb659b63d730"}, 651 | {file = "mypy-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:bd4e1ebe126152a7bbaa4daedd781c90c8f9643c79b9748caa270ad542f12bec"}, 652 | {file = "mypy-1.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a9e056237c89f1587a3be1a3a70a06a698d25e2479b9a2f57325ddaaffc3567b"}, 653 | {file = "mypy-1.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b07e107affb9ee6ce1f342c07f51552d126c32cd62955f59a7db94a51ad12c0"}, 654 | {file = "mypy-1.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6fb60cbd85dc65d4d63d37cb5c86f4e3a301ec605f606ae3a9173e5cf34997b"}, 655 | {file = "mypy-1.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7e32297a437cc915599e0578fa6bc68ae6a8dc059c9e009c628e1c47f91495d"}, 656 | {file = "mypy-1.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:afe420c9380ccec31e744e8baff0d406c846683681025db3531b32db56962d52"}, 657 | {file = "mypy-1.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:55f9076c6ce55dd3f8cd0c6fff26a008ca8e5131b89d5ba6d86bd3f47e736eeb"}, 658 | {file = "mypy-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f56236114c425620875c7cf71700e3d60004858da856c6fc78998ffe767b73d3"}, 659 | {file = "mypy-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:15486beea80be24ff067d7d0ede673b001d0d684d0095803b3e6e17a886a2a92"}, 660 | {file = "mypy-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f2ed0e0847a80655afa2c121835b848ed101cc7b8d8d6ecc5205aedc732b1436"}, 661 | {file = "mypy-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb5fbc8063cb4fde7787e4c0406aa63094a34a2daf4673f359a1fb64050e9cb2"}, 662 | {file = "mypy-1.16.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a5fcfdb7318c6a8dd127b14b1052743b83e97a970f0edb6c913211507a255e20"}, 663 | {file = "mypy-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:2e7e0ad35275e02797323a5aa1be0b14a4d03ffdb2e5f2b0489fa07b89c67b21"}, 664 | {file = "mypy-1.16.0-py3-none-any.whl", hash = "sha256:29e1499864a3888bca5c1542f2d7232c6e586295183320caa95758fc84034031"}, 665 | {file = "mypy-1.16.0.tar.gz", hash = "sha256:84b94283f817e2aa6350a14b4a8fb2a35a53c286f97c9d30f53b63620e7af8ab"}, 666 | ] 667 | 668 | [package.dependencies] 669 | mypy_extensions = ">=1.0.0" 670 | pathspec = ">=0.9.0" 671 | typing_extensions = ">=4.6.0" 672 | 673 | [package.extras] 674 | dmypy = ["psutil (>=4.0)"] 675 | faster-cache = ["orjson"] 676 | install-types = ["pip"] 677 | mypyc = ["setuptools (>=50)"] 678 | reports = ["lxml"] 679 | 680 | [[package]] 681 | name = "mypy-extensions" 682 | version = "1.1.0" 683 | description = "Type system extensions for programs checked with the mypy type checker." 684 | optional = false 685 | python-versions = ">=3.8" 686 | groups = ["dev"] 687 | files = [ 688 | {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, 689 | {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, 690 | ] 691 | 692 | [[package]] 693 | name = "packaging" 694 | version = "25.0" 695 | description = "Core utilities for Python packages" 696 | optional = false 697 | python-versions = ">=3.8" 698 | groups = ["dev"] 699 | files = [ 700 | {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, 701 | {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, 702 | ] 703 | 704 | [[package]] 705 | name = "pathspec" 706 | version = "0.12.1" 707 | description = "Utility library for gitignore style pattern matching of file paths." 708 | optional = false 709 | python-versions = ">=3.8" 710 | groups = ["dev"] 711 | files = [ 712 | {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, 713 | {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, 714 | ] 715 | 716 | [[package]] 717 | name = "pycodestyle" 718 | version = "2.13.0" 719 | description = "Python style guide checker" 720 | optional = false 721 | python-versions = ">=3.9" 722 | groups = ["dev"] 723 | files = [ 724 | {file = "pycodestyle-2.13.0-py2.py3-none-any.whl", hash = "sha256:35863c5974a271c7a726ed228a14a4f6daf49df369d8c50cd9a6f58a5e143ba9"}, 725 | {file = "pycodestyle-2.13.0.tar.gz", hash = "sha256:c8415bf09abe81d9c7f872502a6eee881fbe85d8763dd5b9924bb0a01d67efae"}, 726 | ] 727 | 728 | [[package]] 729 | name = "pycparser" 730 | version = "2.22" 731 | description = "C parser in Python" 732 | optional = false 733 | python-versions = ">=3.8" 734 | groups = ["main"] 735 | markers = "platform_python_implementation != \"PyPy\"" 736 | files = [ 737 | {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, 738 | {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, 739 | ] 740 | 741 | [[package]] 742 | name = "pyflakes" 743 | version = "3.3.2" 744 | description = "passive checker of Python programs" 745 | optional = false 746 | python-versions = ">=3.9" 747 | groups = ["dev"] 748 | files = [ 749 | {file = "pyflakes-3.3.2-py2.py3-none-any.whl", hash = "sha256:5039c8339cbb1944045f4ee5466908906180f13cc99cc9949348d10f82a5c32a"}, 750 | {file = "pyflakes-3.3.2.tar.gz", hash = "sha256:6dfd61d87b97fba5dcfaaf781171ac16be16453be6d816147989e7f6e6a9576b"}, 751 | ] 752 | 753 | [[package]] 754 | name = "pygments" 755 | version = "2.19.1" 756 | description = "Pygments is a syntax highlighting package written in Python." 757 | optional = false 758 | python-versions = ">=3.8" 759 | groups = ["dev"] 760 | files = [ 761 | {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, 762 | {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, 763 | ] 764 | 765 | [package.extras] 766 | windows-terminal = ["colorama (>=0.4.6)"] 767 | 768 | [[package]] 769 | name = "pyjwt" 770 | version = "2.10.1" 771 | description = "JSON Web Token implementation in Python" 772 | optional = false 773 | python-versions = ">=3.9" 774 | groups = ["main"] 775 | files = [ 776 | {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, 777 | {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, 778 | ] 779 | 780 | [package.extras] 781 | crypto = ["cryptography (>=3.4.0)"] 782 | dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] 783 | docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] 784 | tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] 785 | 786 | [[package]] 787 | name = "python-dateutil" 788 | version = "2.9.0.post0" 789 | description = "Extensions to the standard Python datetime module" 790 | optional = false 791 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 792 | groups = ["dev"] 793 | files = [ 794 | {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, 795 | {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, 796 | ] 797 | 798 | [package.dependencies] 799 | six = ">=1.5" 800 | 801 | [[package]] 802 | name = "requests" 803 | version = "2.32.3" 804 | description = "Python HTTP for Humans." 805 | optional = false 806 | python-versions = ">=3.8" 807 | groups = ["dev"] 808 | files = [ 809 | {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, 810 | {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, 811 | ] 812 | 813 | [package.dependencies] 814 | certifi = ">=2017.4.17" 815 | charset-normalizer = ">=2,<4" 816 | idna = ">=2.5,<4" 817 | urllib3 = ">=1.21.1,<3" 818 | 819 | [package.extras] 820 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 821 | use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] 822 | 823 | [[package]] 824 | name = "roman-numerals-py" 825 | version = "3.1.0" 826 | description = "Manipulate well-formed Roman numerals" 827 | optional = false 828 | python-versions = ">=3.9" 829 | groups = ["dev"] 830 | files = [ 831 | {file = "roman_numerals_py-3.1.0-py3-none-any.whl", hash = "sha256:9da2ad2fb670bcf24e81070ceb3be72f6c11c440d73bd579fbeca1e9f330954c"}, 832 | {file = "roman_numerals_py-3.1.0.tar.gz", hash = "sha256:be4bf804f083a4ce001b5eb7e3c0862479d10f94c936f6c4e5f250aa5ff5bd2d"}, 833 | ] 834 | 835 | [package.extras] 836 | lint = ["mypy (==1.15.0)", "pyright (==1.1.394)", "ruff (==0.9.7)"] 837 | test = ["pytest (>=8)"] 838 | 839 | [[package]] 840 | name = "six" 841 | version = "1.17.0" 842 | description = "Python 2 and 3 compatibility utilities" 843 | optional = false 844 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 845 | groups = ["dev"] 846 | files = [ 847 | {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, 848 | {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, 849 | ] 850 | 851 | [[package]] 852 | name = "snowballstemmer" 853 | version = "3.0.1" 854 | description = "This package provides 32 stemmers for 30 languages generated from Snowball algorithms." 855 | optional = false 856 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*" 857 | groups = ["dev"] 858 | files = [ 859 | {file = "snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064"}, 860 | {file = "snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895"}, 861 | ] 862 | 863 | [[package]] 864 | name = "sphinx" 865 | version = "8.2.3" 866 | description = "Python documentation generator" 867 | optional = false 868 | python-versions = ">=3.11" 869 | groups = ["dev"] 870 | files = [ 871 | {file = "sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3"}, 872 | {file = "sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348"}, 873 | ] 874 | 875 | [package.dependencies] 876 | alabaster = ">=0.7.14" 877 | babel = ">=2.13" 878 | colorama = {version = ">=0.4.6", markers = "sys_platform == \"win32\""} 879 | docutils = ">=0.20,<0.22" 880 | imagesize = ">=1.3" 881 | Jinja2 = ">=3.1" 882 | packaging = ">=23.0" 883 | Pygments = ">=2.17" 884 | requests = ">=2.30.0" 885 | roman-numerals-py = ">=1.0.0" 886 | snowballstemmer = ">=2.2" 887 | sphinxcontrib-applehelp = ">=1.0.7" 888 | sphinxcontrib-devhelp = ">=1.0.6" 889 | sphinxcontrib-htmlhelp = ">=2.0.6" 890 | sphinxcontrib-jsmath = ">=1.0.1" 891 | sphinxcontrib-qthelp = ">=1.0.6" 892 | sphinxcontrib-serializinghtml = ">=1.1.9" 893 | 894 | [package.extras] 895 | docs = ["sphinxcontrib-websupport"] 896 | lint = ["betterproto (==2.0.0b6)", "mypy (==1.15.0)", "pypi-attestations (==0.0.21)", "pyright (==1.1.395)", "pytest (>=8.0)", "ruff (==0.9.9)", "sphinx-lint (>=0.9)", "types-Pillow (==10.2.0.20240822)", "types-Pygments (==2.19.0.20250219)", "types-colorama (==0.4.15.20240311)", "types-defusedxml (==0.7.0.20240218)", "types-docutils (==0.21.0.20241128)", "types-requests (==2.32.0.20241016)", "types-urllib3 (==1.26.25.14)"] 897 | test = ["cython (>=3.0)", "defusedxml (>=0.7.1)", "pytest (>=8.0)", "pytest-xdist[psutil] (>=3.4)", "setuptools (>=70.0)", "typing_extensions (>=4.9)"] 898 | 899 | [[package]] 900 | name = "sphinx-rtd-theme" 901 | version = "3.0.2" 902 | description = "Read the Docs theme for Sphinx" 903 | optional = false 904 | python-versions = ">=3.8" 905 | groups = ["dev"] 906 | files = [ 907 | {file = "sphinx_rtd_theme-3.0.2-py2.py3-none-any.whl", hash = "sha256:422ccc750c3a3a311de4ae327e82affdaf59eb695ba4936538552f3b00f4ee13"}, 908 | {file = "sphinx_rtd_theme-3.0.2.tar.gz", hash = "sha256:b7457bc25dda723b20b086a670b9953c859eab60a2a03ee8eb2bb23e176e5f85"}, 909 | ] 910 | 911 | [package.dependencies] 912 | docutils = ">0.18,<0.22" 913 | sphinx = ">=6,<9" 914 | sphinxcontrib-jquery = ">=4,<5" 915 | 916 | [package.extras] 917 | dev = ["bump2version", "transifex-client", "twine", "wheel"] 918 | 919 | [[package]] 920 | name = "sphinxcontrib-applehelp" 921 | version = "2.0.0" 922 | description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" 923 | optional = false 924 | python-versions = ">=3.9" 925 | groups = ["dev"] 926 | files = [ 927 | {file = "sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5"}, 928 | {file = "sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1"}, 929 | ] 930 | 931 | [package.extras] 932 | lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] 933 | standalone = ["Sphinx (>=5)"] 934 | test = ["pytest"] 935 | 936 | [[package]] 937 | name = "sphinxcontrib-devhelp" 938 | version = "2.0.0" 939 | description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents" 940 | optional = false 941 | python-versions = ">=3.9" 942 | groups = ["dev"] 943 | files = [ 944 | {file = "sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2"}, 945 | {file = "sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad"}, 946 | ] 947 | 948 | [package.extras] 949 | lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] 950 | standalone = ["Sphinx (>=5)"] 951 | test = ["pytest"] 952 | 953 | [[package]] 954 | name = "sphinxcontrib-htmlhelp" 955 | version = "2.1.0" 956 | description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" 957 | optional = false 958 | python-versions = ">=3.9" 959 | groups = ["dev"] 960 | files = [ 961 | {file = "sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8"}, 962 | {file = "sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9"}, 963 | ] 964 | 965 | [package.extras] 966 | lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] 967 | standalone = ["Sphinx (>=5)"] 968 | test = ["html5lib", "pytest"] 969 | 970 | [[package]] 971 | name = "sphinxcontrib-jquery" 972 | version = "4.1" 973 | description = "Extension to include jQuery on newer Sphinx releases" 974 | optional = false 975 | python-versions = ">=2.7" 976 | groups = ["dev"] 977 | files = [ 978 | {file = "sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a"}, 979 | {file = "sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae"}, 980 | ] 981 | 982 | [package.dependencies] 983 | Sphinx = ">=1.8" 984 | 985 | [[package]] 986 | name = "sphinxcontrib-jsmath" 987 | version = "1.0.1" 988 | description = "A sphinx extension which renders display math in HTML via JavaScript" 989 | optional = false 990 | python-versions = ">=3.5" 991 | groups = ["dev"] 992 | files = [ 993 | {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, 994 | {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, 995 | ] 996 | 997 | [package.extras] 998 | test = ["flake8", "mypy", "pytest"] 999 | 1000 | [[package]] 1001 | name = "sphinxcontrib-qthelp" 1002 | version = "2.0.0" 1003 | description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents" 1004 | optional = false 1005 | python-versions = ">=3.9" 1006 | groups = ["dev"] 1007 | files = [ 1008 | {file = "sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb"}, 1009 | {file = "sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab"}, 1010 | ] 1011 | 1012 | [package.extras] 1013 | lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] 1014 | standalone = ["Sphinx (>=5)"] 1015 | test = ["defusedxml (>=0.7.1)", "pytest"] 1016 | 1017 | [[package]] 1018 | name = "sphinxcontrib-serializinghtml" 1019 | version = "2.0.0" 1020 | description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)" 1021 | optional = false 1022 | python-versions = ">=3.9" 1023 | groups = ["dev"] 1024 | files = [ 1025 | {file = "sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331"}, 1026 | {file = "sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d"}, 1027 | ] 1028 | 1029 | [package.extras] 1030 | lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] 1031 | standalone = ["Sphinx (>=5)"] 1032 | test = ["pytest"] 1033 | 1034 | [[package]] 1035 | name = "sqlparse" 1036 | version = "0.5.3" 1037 | description = "A non-validating SQL parser." 1038 | optional = false 1039 | python-versions = ">=3.8" 1040 | groups = ["main", "dev"] 1041 | files = [ 1042 | {file = "sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca"}, 1043 | {file = "sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272"}, 1044 | ] 1045 | 1046 | [package.extras] 1047 | dev = ["build", "hatch"] 1048 | doc = ["sphinx"] 1049 | 1050 | [[package]] 1051 | name = "types-cryptography" 1052 | version = "3.3.23.2" 1053 | description = "Typing stubs for cryptography" 1054 | optional = false 1055 | python-versions = "*" 1056 | groups = ["dev"] 1057 | files = [ 1058 | {file = "types-cryptography-3.3.23.2.tar.gz", hash = "sha256:09cc53f273dd4d8c29fa7ad11fefd9b734126d467960162397bc5e3e604dea75"}, 1059 | {file = "types_cryptography-3.3.23.2-py3-none-any.whl", hash = "sha256:b965d548f148f8e87f353ccf2b7bd92719fdf6c845ff7cedf2abb393a0643e4f"}, 1060 | ] 1061 | 1062 | [[package]] 1063 | name = "types-pyyaml" 1064 | version = "6.0.12.20250516" 1065 | description = "Typing stubs for PyYAML" 1066 | optional = false 1067 | python-versions = ">=3.9" 1068 | groups = ["dev"] 1069 | files = [ 1070 | {file = "types_pyyaml-6.0.12.20250516-py3-none-any.whl", hash = "sha256:8478208feaeb53a34cb5d970c56a7cd76b72659442e733e268a94dc72b2d0530"}, 1071 | {file = "types_pyyaml-6.0.12.20250516.tar.gz", hash = "sha256:9f21a70216fc0fa1b216a8176db5f9e0af6eb35d2f2932acb87689d03a5bf6ba"}, 1072 | ] 1073 | 1074 | [[package]] 1075 | name = "typing-extensions" 1076 | version = "4.14.0" 1077 | description = "Backported and Experimental Type Hints for Python 3.9+" 1078 | optional = false 1079 | python-versions = ">=3.9" 1080 | groups = ["dev"] 1081 | files = [ 1082 | {file = "typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af"}, 1083 | {file = "typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4"}, 1084 | ] 1085 | 1086 | [[package]] 1087 | name = "tzdata" 1088 | version = "2025.2" 1089 | description = "Provider of IANA time zone data" 1090 | optional = false 1091 | python-versions = ">=2" 1092 | groups = ["main", "dev"] 1093 | markers = "sys_platform == \"win32\"" 1094 | files = [ 1095 | {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, 1096 | {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, 1097 | ] 1098 | 1099 | [[package]] 1100 | name = "urllib3" 1101 | version = "2.4.0" 1102 | description = "HTTP library with thread-safe connection pooling, file post, and more." 1103 | optional = false 1104 | python-versions = ">=3.9" 1105 | groups = ["dev"] 1106 | files = [ 1107 | {file = "urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813"}, 1108 | {file = "urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466"}, 1109 | ] 1110 | 1111 | [package.extras] 1112 | brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] 1113 | h2 = ["h2 (>=4,<5)"] 1114 | socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] 1115 | zstd = ["zstandard (>=0.18.0)"] 1116 | 1117 | [metadata] 1118 | lock-version = "2.1" 1119 | python-versions = "^3.11" 1120 | content-hash = "c91e529a3a645568ad0481ee33021217c755fe6c77534e270648d8fef0717cf0" 1121 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "asymmetric-jwt-auth" 3 | version = "1.2.0" 4 | description = "Asymmetric key based authentication for HTTP APIs" 5 | authors = ["Craig Weber "] 6 | maintainers = ["Craig Weber "] 7 | readme = "README.rst" 8 | homepage = "https://gitlab.com/thelabnyc/asymmetric-jwt-auth" 9 | repository = "https://gitlab.com/thelabnyc/asymmetric-jwt-auth" 10 | license = "ISC" 11 | classifiers = [ 12 | "Development Status :: 5 - Production/Stable", 13 | "Environment :: Web Environment", 14 | "Framework :: Django", 15 | "Intended Audience :: Developers", 16 | "License :: OSI Approved :: ISC License (ISCL)", 17 | ] 18 | packages = [ 19 | { include = "asymmetric_jwt_auth", from = "src" }, 20 | ] 21 | 22 | 23 | [tool.poetry.dependencies] 24 | python = "^3.11" 25 | cryptography = ">=44.0.2" 26 | Django = ">=4.2" 27 | PyJWT = "^2.10.1" 28 | 29 | [tool.poetry.group.dev.dependencies] 30 | coverage = "^7.8.0" 31 | django-stubs = "^5.1.3" 32 | flake8 = "^7.2.0" 33 | freezegun = "^1.5.1" 34 | mypy = "^1.15.0" 35 | sphinx-rtd-theme = "^3.0.2" 36 | sphinx = "^8.2.3" 37 | types-cryptography = "^3.3.23.2" 38 | 39 | [build-system] 40 | requires = ["poetry-core>=2.1.2"] 41 | build-backend = "poetry.core.masonry.api" 42 | 43 | [tool.commitizen] 44 | name = "cz_conventional_commits" 45 | annotated_tag = true 46 | gpg_sign = true 47 | tag_format = "v$version" 48 | update_changelog_on_bump = true 49 | changelog_merge_prerelease = true 50 | version_provider = "poetry" 51 | version_scheme = "pep440" 52 | version_files = [ 53 | "pyproject.toml:version", 54 | ] 55 | pre_bump_hooks = [ 56 | "pre-commit run --all-files || true", 57 | ] 58 | post_bump_hooks = [ 59 | "git push origin master $CZ_POST_CURRENT_TAG_VERSION" 60 | ] 61 | 62 | [tool.isort] 63 | profile = "black" 64 | from_first = true 65 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["gitlab>thelabnyc/renovate-config:library"] 4 | } 5 | -------------------------------------------------------------------------------- /sandbox/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crgwbr/asymmetric-jwt-auth/7e38b283308de5320c0947afc9dba20f5698e28e/sandbox/__init__.py -------------------------------------------------------------------------------- /sandbox/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | 6 | def main(): 7 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 8 | 9 | from django.core.management import execute_from_command_line 10 | 11 | execute_from_command_line(sys.argv) 12 | 13 | 14 | if __name__ == "__main__": 15 | main() 16 | -------------------------------------------------------------------------------- /sandbox/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | BASE_DIR = os.path.dirname(os.path.abspath(__file__)) 4 | 5 | DEBUG = True 6 | SECRET_KEY = "li0$-gnv)76g$yf7p@(cg-^_q7j6df5cx$o-gsef5hd68phj!4" 7 | SITE_ID = 1 8 | ROOT_URLCONF = "urls" 9 | 10 | INSTALLED_APPS = [ 11 | "django.contrib.admin", 12 | "django.contrib.auth", 13 | "django.contrib.contenttypes", 14 | "django.contrib.sessions", 15 | "django.contrib.sites", 16 | "django.contrib.messages", 17 | "asymmetric_jwt_auth", 18 | ] 19 | 20 | MIDDLEWARE = [ 21 | "django.middleware.security.SecurityMiddleware", 22 | "django.contrib.sessions.middleware.SessionMiddleware", 23 | "django.middleware.locale.LocaleMiddleware", 24 | "django.middleware.common.CommonMiddleware", 25 | "django.middleware.csrf.CsrfViewMiddleware", 26 | "django.contrib.auth.middleware.AuthenticationMiddleware", 27 | "django.contrib.messages.middleware.MessageMiddleware", 28 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 29 | ] 30 | 31 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 32 | DATABASES = { 33 | "default": { 34 | "ENGINE": "django.db.backends.sqlite3", 35 | "NAME": ":memory:", 36 | } 37 | } 38 | 39 | CACHES = { 40 | "default": { 41 | "BACKEND": "django.core.cache.backends.locmem.LocMemCache", 42 | "LOCATION": "jwt-testing-sandbox", 43 | } 44 | } 45 | 46 | TEMPLATES = [ 47 | { 48 | "BACKEND": "django.template.backends.django.DjangoTemplates", 49 | "DIRS": [], 50 | "APP_DIRS": True, 51 | "OPTIONS": { 52 | "context_processors": [ 53 | "django.template.context_processors.debug", 54 | "django.template.context_processors.request", 55 | "django.contrib.auth.context_processors.auth", 56 | "django.contrib.messages.context_processors.messages", 57 | ], 58 | }, 59 | }, 60 | ] 61 | -------------------------------------------------------------------------------- /sandbox/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import include, path 3 | 4 | urlpatterns = [ 5 | path("i18n/", include("django.conf.urls.i18n")), 6 | path("admin/", admin.site.urls), 7 | path("", include("asymmetric_jwt_auth.urls")), 8 | ] 9 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = W503 3 | extend-ignore = E203 4 | max-line-length = 160 5 | exclude = migrations 6 | 7 | [mypy] 8 | python_version = 3.11 9 | 10 | # Strict mode, see mypy --help 11 | warn_unused_configs = True 12 | disallow_subclassing_any = True 13 | disallow_any_generics = True 14 | disallow_untyped_calls = True 15 | disallow_untyped_defs = True 16 | disallow_incomplete_defs = True 17 | check_untyped_defs = True 18 | disallow_untyped_decorators = True 19 | no_implicit_optional = True 20 | warn_redundant_casts = True 21 | warn_unused_ignores = True 22 | warn_return_any = True 23 | ; no_implicit_reexport = True 24 | show_error_codes = True 25 | # Not turned on by strict 26 | strict_equality = True 27 | 28 | plugins = 29 | mypy_django_plugin.main 30 | 31 | [mypy.plugins.django-stubs] 32 | django_settings_module = "sandbox.settings" 33 | 34 | [mypy-*.migrations.*] 35 | ignore_errors = True 36 | 37 | [mypy-*.tests.*] 38 | ignore_errors = True 39 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | import copy 3 | 4 | from django.conf import settings 5 | 6 | #: Default settings. Override using a dictionary named ASYMMETRIC_JWT_AUTH in Django's settings.py. 7 | default_settings = { 8 | #: Auth method searched for in the prefix of the Authentication header. Similar to ``Bearer`` or ``Basic``. 9 | "AUTH_METHOD": "JWT", 10 | #: Number of seconds of clock-drift to tolerate when verifying the authenticity of a JWT. 11 | "TIMESTAMP_TOLERANCE": 20, # Seconds 12 | #: Class used to store and validate nonces 13 | "NONCE_BACKEND": "asymmetric_jwt_auth.nonce.django.DjangoCacheNonceBackend", 14 | #: Repository class used to fetch users by their username 15 | "USER_REPOSITORY": "asymmetric_jwt_auth.repos.django.DjangoUserRepository", 16 | #: List of repository classes used to fetch public keys for a user 17 | "PUBLIC_KEY_REPOSITORIES": [ 18 | "asymmetric_jwt_auth.repos.django.DjangoJWKSRepository", 19 | "asymmetric_jwt_auth.repos.django.DjangoPublicKeyListRepository", 20 | ], 21 | #: List of public keys that should be advertised on our JWKS endpoint. 22 | "SIGNING_PUBLIC_KEYS": [], 23 | #: Cache TTL for the JWKS key view 24 | "JWKS_VIEW_TTL": (60 * 5), 25 | } 26 | 27 | 28 | def get_setting(name: str) -> Any: 29 | _settings = copy.deepcopy(default_settings) 30 | _settings.update(getattr(settings, "ASYMMETRIC_JWT_AUTH", {})) 31 | return _settings[name] 32 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from . import models 4 | 5 | 6 | @admin.register(models.PublicKey) 7 | class PublicKeyAdmin(admin.ModelAdmin): # type:ignore[type-arg] 8 | list_display = ["user", "comment", "last_used_on"] 9 | fields = ["user", "comment", "key", "last_used_on"] 10 | raw_id_fields = ["user"] 11 | 12 | 13 | @admin.register(models.JWKSEndpointTrust) 14 | class JWKSEndpointTrustAdmin(admin.ModelAdmin): # type:ignore[type-arg] 15 | list_display = ["user", "jwks_url", "last_used_on"] 16 | fields = ["user", "jwks_url", "last_used_on"] 17 | raw_id_fields = ["user"] 18 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class JWTAuthConfig(AppConfig): 5 | name = "asymmetric_jwt_auth" 6 | verbose_name = "Asymmetric Key Authentication" 7 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/keys.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Generic, TypeVar, Union 2 | import hashlib 3 | import os 4 | 5 | from cryptography.hazmat.primitives import serialization 6 | from cryptography.hazmat.primitives.asymmetric import ed25519, rsa 7 | 8 | from .utils import long_to_base64 9 | 10 | CryptoPrivateKey = Union[rsa.RSAPrivateKey, ed25519.Ed25519PrivateKey] 11 | CryptoPublicKey = Union[rsa.RSAPublicKey, ed25519.Ed25519PublicKey] 12 | 13 | FacadePrivateKey = Union["RSAPrivateKey", "Ed25519PrivateKey"] 14 | FacadePublicKey = Union["RSAPublicKey", "Ed25519PublicKey"] 15 | 16 | PrivateKeyType = TypeVar( 17 | "PrivateKeyType", 18 | bound=Union[rsa.RSAPrivateKey, ed25519.Ed25519PrivateKey], 19 | ) 20 | PublicKeyType = TypeVar( 21 | "PublicKeyType", 22 | bound=Union[rsa.RSAPublicKey, ed25519.Ed25519PublicKey], 23 | ) 24 | 25 | 26 | class PublicKey(Generic[PublicKeyType]): 27 | """Represents a public key""" 28 | 29 | _key: PublicKeyType 30 | 31 | @staticmethod 32 | def from_cryptography_pubkey(pubkey: CryptoPublicKey) -> FacadePublicKey: 33 | if isinstance(pubkey, rsa.RSAPublicKey): 34 | return RSAPublicKey(pubkey) 35 | if isinstance(pubkey, ed25519.Ed25519PublicKey): 36 | return Ed25519PublicKey(pubkey) 37 | raise TypeError(f"Unknown key type: {pubkey}") 38 | 39 | @classmethod 40 | def load_pem(cls, pem: bytes) -> FacadePublicKey: 41 | """ 42 | Load a PEM-format public key 43 | """ 44 | privkey = serialization.load_pem_public_key(pem) 45 | return cls.from_cryptography_pubkey(privkey) 46 | 47 | @classmethod 48 | def load_openssh(cls, key: bytes) -> FacadePublicKey: 49 | """ 50 | Load a openssh-format public key 51 | """ 52 | privkey = serialization.load_ssh_public_key(key) 53 | return cls.from_cryptography_pubkey(privkey) 54 | 55 | @classmethod 56 | def load_serialized_public_key( 57 | cls, key: bytes 58 | ) -> tuple[Exception | None, FacadePublicKey | None]: 59 | """ 60 | Load a PEM or openssh format public key 61 | """ 62 | exc = None 63 | for loader in (cls.load_pem, cls.load_openssh): 64 | try: 65 | return None, loader(key) 66 | except Exception as e: 67 | exc = e 68 | return exc, None 69 | 70 | @property 71 | def as_pem(self) -> bytes: 72 | """ 73 | Get the public key as a PEM-formatted byte string 74 | """ 75 | pem_bytes = self._key.public_bytes( 76 | serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo 77 | ) 78 | return pem_bytes 79 | 80 | @property 81 | def as_jwk(self) -> dict[str, str]: # pragma: no cover 82 | """ 83 | Return the public key in JWK format 84 | """ 85 | raise NotImplementedError("Subclass does not implement as_jwk method") 86 | 87 | @property 88 | def fingerprint(self) -> str: 89 | """ 90 | Get a sha256 fingerprint of the key. 91 | """ 92 | return hashlib.sha256(self.as_pem).hexdigest() 93 | 94 | @property 95 | def allowed_algorithms(self) -> list[str]: # pragma: no cover 96 | """ 97 | Return a list of allowed JWT algorithms for this key, in order of most to least preferred. 98 | """ 99 | raise NotImplementedError( 100 | "Subclass does not implement allowed_algorithms method" 101 | ) 102 | 103 | 104 | class RSAPublicKey(PublicKey[rsa.RSAPublicKey]): 105 | """Represents an RSA public key""" 106 | 107 | def __init__(self, key: rsa.RSAPublicKey): 108 | self._key = key 109 | 110 | @property 111 | def as_jwk(self) -> dict[str, str]: 112 | """ 113 | Return the public key in JWK format 114 | """ 115 | public_numbers = self._key.public_numbers() 116 | return { 117 | "kty": "RSA", 118 | "use": "sig", 119 | "alg": self.allowed_algorithms[0], 120 | "kid": self.fingerprint, 121 | "n": long_to_base64(public_numbers.n), 122 | "e": long_to_base64(public_numbers.e), 123 | } 124 | 125 | @property 126 | def allowed_algorithms(self) -> list[str]: 127 | return [ 128 | "RS512", 129 | "RS384", 130 | "RS256", 131 | ] 132 | 133 | 134 | class Ed25519PublicKey(PublicKey[ed25519.Ed25519PublicKey]): 135 | """Represents an Ed25519 public key""" 136 | 137 | def __init__(self, key: ed25519.Ed25519PublicKey): 138 | self._key = key 139 | 140 | @property 141 | def allowed_algorithms(self) -> list[str]: 142 | return [ 143 | "EdDSA", 144 | ] 145 | 146 | 147 | class PrivateKey(Generic[PrivateKeyType]): 148 | """Represents a private key""" 149 | 150 | _key: PrivateKeyType 151 | 152 | @staticmethod 153 | def from_cryptography_privkey(privkey: CryptoPrivateKey) -> FacadePrivateKey: 154 | if isinstance(privkey, rsa.RSAPrivateKey): 155 | return RSAPrivateKey(privkey) 156 | if isinstance(privkey, ed25519.Ed25519PrivateKey): 157 | return Ed25519PrivateKey(privkey) 158 | raise TypeError("Unknown key type") 159 | 160 | @classmethod 161 | def load_pem_from_file( 162 | cls, 163 | filepath: os.PathLike[Any], 164 | password: bytes | None = None, 165 | ) -> FacadePrivateKey: 166 | """ 167 | Load a PEM-format private key from disk. 168 | """ 169 | with open(filepath, "rb") as fh: 170 | key_bytes = fh.read() 171 | return cls.load_pem(key_bytes, password=password) 172 | 173 | @classmethod 174 | def load_pem(cls, pem: bytes, password: bytes | None = None) -> FacadePrivateKey: 175 | """ 176 | Load a PEM-format private key 177 | """ 178 | privkey = serialization.load_pem_private_key(pem, password=password) 179 | return cls.from_cryptography_privkey(privkey) 180 | 181 | @property 182 | def as_pem(self) -> bytes: 183 | pem_bytes = self._key.private_bytes( # type:ignore[union-attr] 184 | serialization.Encoding.PEM, 185 | serialization.PrivateFormat.PKCS8, 186 | serialization.NoEncryption(), 187 | ) 188 | return pem_bytes 189 | 190 | @property 191 | def public_key(self) -> FacadePublicKey: # pragma: no cover 192 | raise NotImplementedError() 193 | 194 | 195 | class RSAPrivateKey(PrivateKey[rsa.RSAPrivateKey]): 196 | """Represents an RSA private key""" 197 | 198 | pubkey_cls = RSAPublicKey 199 | 200 | @classmethod 201 | def generate( 202 | cls, size: int = 2048, public_exponent: int = 65537 203 | ) -> "RSAPrivateKey": 204 | """ 205 | Generate an RSA private key. 206 | """ 207 | private = rsa.generate_private_key( 208 | public_exponent=public_exponent, key_size=size 209 | ) 210 | return cls(private) 211 | 212 | def __init__(self, key: rsa.RSAPrivateKey): 213 | self._key = key 214 | 215 | @property 216 | def public_key(self) -> FacadePublicKey: 217 | public = self._key.public_key() 218 | return self.pubkey_cls(public) 219 | 220 | 221 | class Ed25519PrivateKey(PrivateKey[ed25519.Ed25519PrivateKey]): 222 | """Represents an Ed25519 private key""" 223 | 224 | pubkey_cls = Ed25519PublicKey 225 | 226 | @classmethod 227 | def generate(cls) -> "Ed25519PrivateKey": 228 | """ 229 | Generate an Ed25519 private key. 230 | """ 231 | private = ed25519.Ed25519PrivateKey.generate() 232 | return cls(private) 233 | 234 | def __init__(self, key: ed25519.Ed25519PrivateKey): 235 | self._key = key 236 | 237 | @property 238 | def public_key(self) -> FacadePublicKey: 239 | public = self._key.public_key() 240 | return self.pubkey_cls(public) 241 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crgwbr/asymmetric-jwt-auth/7e38b283308de5320c0947afc9dba20f5698e28e/src/asymmetric_jwt_auth/management/__init__.py -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crgwbr/asymmetric-jwt-auth/7e38b283308de5320c0947afc9dba20f5698e28e/src/asymmetric_jwt_auth/management/commands/__init__.py -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/management/commands/generate_key_pair.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentParser 2 | from typing import Any 3 | 4 | from django.core.management.base import BaseCommand 5 | 6 | from asymmetric_jwt_auth.keys import Ed25519PrivateKey, FacadePrivateKey, RSAPrivateKey 7 | 8 | TYPE_RSA = "RSA" 9 | TYPE_ED25519 = "Ed25519" 10 | TYPE_CHOICES = [ 11 | TYPE_RSA, 12 | TYPE_ED25519, 13 | ] 14 | 15 | 16 | class Command(BaseCommand): 17 | help = "Generate a public / private RSA key pair" 18 | 19 | def add_arguments(self, parser: ArgumentParser) -> None: 20 | parser.add_argument("-t", "--keytype", choices=TYPE_CHOICES, default=TYPE_RSA) 21 | 22 | def handle(self, *args: Any, keytype: str = TYPE_RSA, **options: Any) -> None: 23 | privkey: FacadePrivateKey 24 | if keytype == TYPE_ED25519: 25 | privkey = Ed25519PrivateKey.generate() 26 | else: 27 | privkey = RSAPrivateKey.generate() 28 | self.stdout.write(privkey.as_pem.decode()) 29 | self.stdout.write(privkey.public_key.as_pem.decode()) 30 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/middleware.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | import logging 3 | 4 | from django.http import HttpRequest, HttpResponse 5 | 6 | from . import get_setting 7 | from .nonce import get_nonce_backend 8 | from .repos import get_public_key_repositories, get_user_repository 9 | from .tokens import UntrustedToken 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class JWTAuthMiddleware: 15 | """Django middleware class for authenticating users using JWT Authentication headers""" 16 | 17 | def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): 18 | self.get_response = get_response 19 | self.nonce_backend = get_nonce_backend() 20 | self.user_repo = get_user_repository() 21 | self.key_repos = get_public_key_repositories() 22 | 23 | def __call__(self, request: HttpRequest) -> HttpResponse: 24 | # Attempt to authorize the request 25 | self.authorize_request(request) 26 | # Continue with the request 27 | return self.get_response(request) 28 | 29 | def authorize_request(self, request: HttpRequest) -> HttpRequest: 30 | """ 31 | Process a Django request and authenticate users. 32 | 33 | If a JWT authentication header is detected and it is determined to be valid, the user is set as 34 | ``request.user`` and CSRF protection is disabled (``request._dont_enforce_csrf_checks = True``) on 35 | the request. 36 | 37 | :param request: Django Request instance 38 | """ 39 | # Check for presence of auth header 40 | if "authorization" not in request.headers: 41 | return request 42 | 43 | # Ensure this auth header was meant for us (it has the JWT auth method). 44 | try: 45 | method, header_data = request.headers["authorization"].split(" ", 1) 46 | except ValueError: 47 | return request 48 | 49 | auth_method_setting = get_setting("AUTH_METHOD") 50 | if method.upper() != auth_method_setting: 51 | return request 52 | 53 | # Get the (unvalidated!) username that the request is claiming to be 54 | untrusted_token = UntrustedToken(header_data) 55 | username = untrusted_token.get_claimed_username() 56 | if not username: 57 | return request 58 | 59 | # Get the user model 60 | user = self.user_repo.get_user(username=username) 61 | if not user: 62 | return request 63 | 64 | # Try and validate the token using a key from the key repo 65 | verified_token = None 66 | for repo in self.key_repos: 67 | verified_token = repo.attempt_to_verify_token(user, untrusted_token) 68 | if verified_token: 69 | break 70 | 71 | # No keys successfully validated the claim? Abort. 72 | if not verified_token: 73 | return request 74 | 75 | # Assign the user to the request 76 | logger.debug("Successfully authenticated %s using JWT", user.username) 77 | request._dont_enforce_csrf_checks = True # type:ignore[attr-defined] 78 | request.user = user 79 | return request 80 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import migrations, models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | dependencies = [ 7 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 8 | ] 9 | 10 | operations = [ 11 | migrations.CreateModel( 12 | name="PublicKey", 13 | fields=[ 14 | ( 15 | "id", 16 | models.AutoField( 17 | serialize=False, 18 | auto_created=True, 19 | verbose_name="ID", 20 | primary_key=True, 21 | ), 22 | ), 23 | ("key", models.TextField(help_text="The user's RSA public key")), 24 | ( 25 | "user", 26 | models.ForeignKey( 27 | to=settings.AUTH_USER_MODEL, 28 | related_name="public_keys", 29 | on_delete=models.CASCADE, 30 | ), 31 | ), 32 | ], 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/migrations/0002_publickey_comment.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [ 6 | ("asymmetric_jwt_auth", "0001_initial"), 7 | ] 8 | 9 | operations = [ 10 | migrations.AddField( 11 | model_name="publickey", 12 | name="comment", 13 | field=models.CharField( 14 | max_length=100, help_text="Comment describing this key", default="" 15 | ), 16 | preserve_default=False, 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/migrations/0003_auto_20151112_1547.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [ 6 | ("asymmetric_jwt_auth", "0002_publickey_comment"), 7 | ] 8 | 9 | operations = [ 10 | migrations.AlterField( 11 | model_name="publickey", 12 | name="comment", 13 | field=models.CharField( 14 | blank=True, max_length=100, help_text="Comment describing this key" 15 | ), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/migrations/0004_auto_20191104_1628.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-11-04 16:28 2 | 3 | from django.db import migrations, models 4 | 5 | import asymmetric_jwt_auth.models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("asymmetric_jwt_auth", "0003_auto_20151112_1547"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="publickey", 16 | name="last_used_on", 17 | field=models.DateTimeField( 18 | blank=True, null=True, verbose_name="Last Used On" 19 | ), 20 | ), 21 | migrations.AlterField( 22 | model_name="publickey", 23 | name="key", 24 | field=models.TextField( 25 | help_text="The user's RSA public key", 26 | validators=[asymmetric_jwt_auth.models.validate_public_key], 27 | ), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/migrations/0005_auto_20210304_1116.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-03-04 11:16 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | import asymmetric_jwt_auth.models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | dependencies = [ 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ("asymmetric_jwt_auth", "0004_auto_20191104_1628"), 14 | ] 15 | 16 | operations = [ 17 | migrations.AlterModelOptions( 18 | name="publickey", 19 | options={ 20 | "verbose_name": "Public Key", 21 | "verbose_name_plural": "Public Keys", 22 | }, 23 | ), 24 | migrations.AlterField( 25 | model_name="publickey", 26 | name="comment", 27 | field=models.CharField( 28 | blank=True, 29 | help_text="Comment describing this key", 30 | max_length=100, 31 | verbose_name="Comment", 32 | ), 33 | ), 34 | migrations.AlterField( 35 | model_name="publickey", 36 | name="key", 37 | field=models.TextField( 38 | help_text="The user's RSA public key", 39 | validators=[asymmetric_jwt_auth.models.validate_public_key], 40 | verbose_name="Public Key", 41 | ), 42 | ), 43 | migrations.AlterField( 44 | model_name="publickey", 45 | name="user", 46 | field=models.ForeignKey( 47 | on_delete=django.db.models.deletion.CASCADE, 48 | related_name="public_keys", 49 | to=settings.AUTH_USER_MODEL, 50 | verbose_name="User", 51 | ), 52 | ), 53 | migrations.CreateModel( 54 | name="JWKSEndpointTrust", 55 | fields=[ 56 | ( 57 | "id", 58 | models.AutoField( 59 | auto_created=True, 60 | primary_key=True, 61 | serialize=False, 62 | verbose_name="ID", 63 | ), 64 | ), 65 | ( 66 | "jwks_url", 67 | models.URLField( 68 | help_text="e.g. https://dev-87evx9ru.auth0.com/.well-known/jwks.json", 69 | verbose_name="JSON Web Key Set (JWKS) URL", 70 | ), 71 | ), 72 | ( 73 | "user", 74 | models.OneToOneField( 75 | on_delete=django.db.models.deletion.CASCADE, 76 | related_name="jwks_endpoint", 77 | to=settings.AUTH_USER_MODEL, 78 | verbose_name="User", 79 | ), 80 | ), 81 | ], 82 | options={ 83 | "verbose_name": "JSON Web Key Set", 84 | "verbose_name_plural": "JSON Web Key Sets", 85 | }, 86 | ), 87 | ] 88 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/migrations/0006_jwksendpointtrust_last_used_on_alter_publickey_key.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.4 on 2025-02-05 16:43 2 | 3 | from django.db import migrations, models 4 | 5 | import asymmetric_jwt_auth.models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("asymmetric_jwt_auth", "0005_auto_20210304_1116"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="jwksendpointtrust", 17 | name="last_used_on", 18 | field=models.DateTimeField( 19 | blank=True, null=True, verbose_name="Last Used On" 20 | ), 21 | ), 22 | migrations.AlterField( 23 | model_name="publickey", 24 | name="key", 25 | field=models.TextField( 26 | help_text="The user's RSA/Ed25519 public key", 27 | validators=[asymmetric_jwt_auth.models.validate_public_key], 28 | verbose_name="Public Key", 29 | ), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crgwbr/asymmetric-jwt-auth/7e38b283308de5320c0947afc9dba20f5698e28e/src/asymmetric_jwt_auth/migrations/__init__.py -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/models.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | from typing import Any 3 | 4 | from django.conf import settings 5 | from django.core.exceptions import ValidationError 6 | from django.db import models 7 | from django.utils import timezone 8 | from django.utils.encoding import force_bytes, force_str 9 | from django.utils.translation import gettext_lazy as _ 10 | from jwt import PyJWKClient 11 | 12 | from . import keys, tokens 13 | 14 | 15 | def validate_public_key(keystr: str) -> None: 16 | """ 17 | Check that the given value is a valid public key in either PEM or OpenSSH format. If it is invalid, 18 | raises ``django.core.exceptions.ValidationError``. 19 | """ 20 | key_bytes = keystr.encode() 21 | exc, key = keys.PublicKey.load_serialized_public_key(key_bytes) 22 | is_valid = (exc is None) and (key is not None) 23 | if not is_valid: 24 | raise ValidationError("Public key is invalid: %s" % exc) 25 | 26 | 27 | @lru_cache 28 | def get_jwks_client(jwks_url: str) -> PyJWKClient: 29 | """ 30 | LRU-cached constructor for PyJWKClient. Re-using instances of this (scoped 31 | by the URL of course) allows the JWKS caching inside PyJWKClient to function 32 | correctly. 33 | """ 34 | return PyJWKClient(jwks_url) 35 | 36 | 37 | class PublicKey(models.Model): 38 | """ 39 | Store a public key and associate it to a particular user. 40 | 41 | Implements the same concept as the OpenSSH ``~/.ssh/authorized_keys`` file on a Unix system. 42 | """ 43 | 44 | #: Foreign key to the Django User model. Related name: ``public_keys``. 45 | user = models.ForeignKey( 46 | settings.AUTH_USER_MODEL, 47 | verbose_name=_("User"), 48 | related_name="public_keys", 49 | on_delete=models.CASCADE, 50 | ) 51 | 52 | #: Key text in either PEM or OpenSSH format. 53 | key = models.TextField( 54 | _("Public Key"), 55 | help_text=_("The user's RSA/Ed25519 public key"), 56 | validators=[validate_public_key], 57 | ) 58 | 59 | #: Comment describing the key. Use this to note what system is authenticating with the key, when it was last rotated, etc. 60 | comment = models.CharField( 61 | _("Comment"), 62 | max_length=100, 63 | help_text=_("Comment describing this key"), 64 | blank=True, 65 | ) 66 | 67 | #: Date and time that key was last used for authenticating a request. 68 | last_used_on = models.DateTimeField(_("Last Used On"), null=True, blank=True) 69 | 70 | class Meta: 71 | verbose_name = _("Public Key") 72 | verbose_name_plural = _("Public Keys") 73 | 74 | def get_key(self) -> keys.FacadePublicKey: 75 | key_bytes = force_bytes(self.key) 76 | exc, key = keys.PublicKey.load_serialized_public_key(key_bytes) 77 | if key is None: 78 | if exc is None: # pragma: no cover 79 | raise ValueError("Failed to load key") 80 | raise exc 81 | return key 82 | 83 | def update_last_used_datetime(self) -> None: 84 | self.last_used_on = timezone.now() 85 | self.save(update_fields=["last_used_on"]) 86 | 87 | def save(self, *args: Any, **kwargs: Any) -> None: 88 | key_parts = force_str(self.key).split(" ") 89 | if len(key_parts) == 3 and not self.comment: 90 | self.comment = key_parts.pop() 91 | super().save(*args, **kwargs) 92 | 93 | 94 | class JWKSEndpointTrust(models.Model): 95 | """ 96 | Associate a JSON Web Key Set (JWKS) URL with a Django User. 97 | 98 | This accomplishes the same purpose of the PublicKey model, in a more automated 99 | fashion. Instead of manually assigning a public key to a user, the system will 100 | load a list of public keys from this URL. 101 | """ 102 | 103 | #: Foreign key to the Django User model. Related name: ``public_keys``. 104 | user = models.OneToOneField( 105 | settings.AUTH_USER_MODEL, 106 | verbose_name=_("User"), 107 | related_name="jwks_endpoint", 108 | on_delete=models.CASCADE, 109 | ) 110 | 111 | #: URL of the JSON Web Key Set (JWKS) 112 | jwks_url = models.URLField( 113 | _("JSON Web Key Set (JWKS) URL"), 114 | help_text=_("e.g. https://dev-87evx9ru.auth0.com/.well-known/jwks.json"), 115 | ) 116 | 117 | #: Date and time that key was last used for authenticating a request. 118 | last_used_on = models.DateTimeField(_("Last Used On"), null=True, blank=True) 119 | 120 | class Meta: 121 | verbose_name = _("JSON Web Key Set") 122 | verbose_name_plural = _("JSON Web Key Sets") 123 | 124 | @property 125 | def jwks_client(self) -> PyJWKClient: 126 | return get_jwks_client(self.jwks_url) 127 | 128 | def get_signing_key( 129 | self, untrusted_token: tokens.UntrustedToken 130 | ) -> keys.FacadePublicKey: 131 | jwk = self.jwks_client.get_signing_key_from_jwt(untrusted_token.token) 132 | return keys.PublicKey.from_cryptography_pubkey(jwk.key) 133 | 134 | def update_last_used_datetime(self) -> None: 135 | self.last_used_on = timezone.now() 136 | self.save(update_fields=["last_used_on"]) 137 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/nonce/__init__.py: -------------------------------------------------------------------------------- 1 | from django.utils.module_loading import import_string 2 | 3 | from .. import get_setting 4 | from .base import BaseNonceBackend 5 | 6 | 7 | def get_nonce_backend() -> BaseNonceBackend: 8 | backend_path = get_setting("NONCE_BACKEND") 9 | Backend: type[BaseNonceBackend] = import_string(backend_path) 10 | return Backend() 11 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/nonce/base.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | 3 | 4 | class BaseNonceBackend: 5 | def validate_nonce( 6 | self, username: str, timestamp: int, nonce: str 7 | ) -> bool: # pragma: no cover 8 | raise NotImplementedError() 9 | 10 | def log_used_nonce( 11 | self, username: str, timestamp: int, nonce: str 12 | ) -> None: # pragma: no cover 13 | raise NotImplementedError() 14 | 15 | def generate_nonce(self) -> str: 16 | return secrets.token_urlsafe(nbytes=8) 17 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/nonce/django.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.cache import cache 3 | 4 | from .. import default_settings 5 | from . import BaseNonceBackend 6 | 7 | 8 | class DjangoCacheNonceBackend(BaseNonceBackend): 9 | """ 10 | Nonce backend which uses DJango's cache system. 11 | 12 | Simple, but not great. Prone to race conditions. 13 | """ 14 | 15 | def validate_nonce(self, username: str, timestamp: int, nonce: str) -> bool: 16 | """ 17 | Confirm that the given nonce hasn't already been used. 18 | """ 19 | key = self._create_nonce_key(username, timestamp) 20 | used = cache.get(key, set()) 21 | return nonce not in used 22 | 23 | def log_used_nonce(self, username: str, timestamp: int, nonce: str) -> None: 24 | """ 25 | Log a nonce as being used, and therefore henceforth invalid. 26 | """ 27 | key = self._create_nonce_key(username, timestamp) 28 | used = cache.get(key, set()) 29 | used.add(nonce) 30 | timestamp_tolerance = getattr( 31 | settings, "ASYMMETRIC_JWT_AUTH", default_settings 32 | )["TIMESTAMP_TOLERANCE"] 33 | cache.set(key, used, timestamp_tolerance * 2) 34 | 35 | def _create_nonce_key(self, username: str, timestamp: int) -> str: 36 | """ 37 | Create and return the cache key for storing nonces 38 | """ 39 | return "{}-nonces-{}-{}".format( 40 | self.__class__.__name__, 41 | username, 42 | timestamp, 43 | ) 44 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/nonce/null.py: -------------------------------------------------------------------------------- 1 | from . import BaseNonceBackend 2 | 3 | 4 | class NullNonceBackend(BaseNonceBackend): 5 | """ 6 | Nonce backend which doesn't actually do anything 7 | """ 8 | 9 | def validate_nonce(self, username: str, timestamp: int, nonce: str) -> bool: 10 | """ 11 | Confirm that the given nonce hasn't already been used. 12 | """ 13 | return True 14 | 15 | def log_used_nonce(self, username: str, timestamp: int, nonce: str) -> None: 16 | """ 17 | Log a nonce as being used, and therefore henceforth invalid. 18 | """ 19 | pass 20 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crgwbr/asymmetric-jwt-auth/7e38b283308de5320c0947afc9dba20f5698e28e/src/asymmetric_jwt_auth/py.typed -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/repos/__init__.py: -------------------------------------------------------------------------------- 1 | from django.utils.module_loading import import_string 2 | 3 | from .. import get_setting 4 | from .base import BasePublicKeyRepository, BaseUserRepository 5 | 6 | 7 | def get_user_repository() -> BaseUserRepository: 8 | Repo: type[BaseUserRepository] = import_string(get_setting("USER_REPOSITORY")) 9 | return Repo() 10 | 11 | 12 | def get_public_key_repositories() -> list[BasePublicKeyRepository]: 13 | repos = [] 14 | for cls_path in get_setting("PUBLIC_KEY_REPOSITORIES"): 15 | Repo = import_string(cls_path) 16 | repo = Repo() 17 | repos.append(repo) 18 | return repos 19 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/repos/base.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | 3 | from ..tokens import Token, UntrustedToken 4 | 5 | 6 | class BaseUserRepository: 7 | def get_user(self, username: str) -> None | User: # pragma: no cover 8 | raise NotImplementedError() 9 | 10 | 11 | class BasePublicKeyRepository: 12 | def attempt_to_verify_token( 13 | self, user: User, untrusted_token: UntrustedToken 14 | ) -> Token | None: # pragma: no cover 15 | raise NotImplementedError() 16 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/repos/django.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.contrib.auth.models import User 3 | from jwt.exceptions import PyJWKClientError 4 | 5 | from .. import models 6 | from ..tokens import Token, UntrustedToken 7 | from .base import BasePublicKeyRepository, BaseUserRepository 8 | 9 | 10 | class DjangoUserRepository(BaseUserRepository): 11 | def __init__(self) -> None: 12 | self.User = get_user_model() 13 | 14 | def get_user(self, username: str) -> None | User: 15 | """ 16 | Get a Django user by username 17 | """ 18 | try: 19 | return self.User.objects.get(username=username) 20 | except self.User.DoesNotExist: 21 | pass 22 | return None 23 | 24 | 25 | class DjangoPublicKeyListRepository(BasePublicKeyRepository): 26 | def attempt_to_verify_token( 27 | self, 28 | user: User, 29 | untrusted_token: UntrustedToken, 30 | ) -> Token | None: 31 | """ 32 | Attempt to verify a JWT for the given user using public keys from the PublicKey model. 33 | """ 34 | for user_key in models.PublicKey.objects.filter(user=user).all(): 35 | public_key = user_key.get_key() 36 | token = untrusted_token.verify(public_key=public_key) 37 | if token: 38 | user_key.update_last_used_datetime() 39 | return token 40 | return None 41 | 42 | 43 | class DjangoJWKSRepository(BasePublicKeyRepository): 44 | def attempt_to_verify_token( 45 | self, 46 | user: User, 47 | untrusted_token: UntrustedToken, 48 | ) -> Token | None: 49 | """ 50 | Attempt to verify a JWT for the given user using public keys the user's JWKS endpoint. 51 | """ 52 | jwks_endpoints = models.JWKSEndpointTrust.objects.filter(user=user).all() 53 | for jwks_endpoint in jwks_endpoints: 54 | try: 55 | public_key = jwks_endpoint.get_signing_key(untrusted_token) 56 | except PyJWKClientError: 57 | continue 58 | token = untrusted_token.verify(public_key=public_key) 59 | if token: 60 | jwks_endpoint.update_last_used_datetime() 61 | return token 62 | return None 63 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crgwbr/asymmetric-jwt-auth/7e38b283308de5320c0947afc9dba20f5698e28e/src/asymmetric_jwt_auth/tests/__init__.py -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/tests/data.py: -------------------------------------------------------------------------------- 1 | PEM_PUBLIC_RSA = b""" 2 | -----BEGIN PUBLIC KEY----- 3 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAodxbRh5LOtoB3Shf6K3m 4 | Rn7ME7Doo5Qm5h72ITt+E6U0l6qXGdVBTj0XhQVNnGjnZTGzU7IacIw1a/03qVHJ 5 | fcc0Ki7ig7YSPMMl0WSp0m080YlsCZ+9g+WG6DrgjpGQU7yaBhNsKtR5DP20bm84 6 | 11S9VLqV2GEOzBKpB10/lwhRZuv/Qj7obwSqdVCzMNb7t5LHqG0MxOF7BeYELXIq 7 | TEKFfWkZytXCAnmC9hk9RtzUZ/lryD1UgCHZ16gPtmPdFV7fuN8FBNrbaQCldz6V 8 | 6HVDjsPVxPmVYswV8qInG8kJUpm48s9PAWfgi4HCGmJgn+Irbed2tlRf73jxyCgX 9 | 0QIDAQAB 10 | -----END PUBLIC KEY----- 11 | """ 12 | 13 | PEM_PUBLIC_ED25519 = b""" 14 | -----BEGIN PUBLIC KEY----- 15 | MCowBQYDK2VwAyEAhRk96LXVjEtq8yI1I5LiRiv0OHiGvgJKfU0a4SweOe0= 16 | -----END PUBLIC KEY----- 17 | """ 18 | 19 | PEM_PUBLIC_RSA_INVALID = b""" 20 | -----BEGIN PUBLIC KEY----- 21 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAodxbRh5LOtoB3Shf6K3m 22 | Rn7ME7Doo5Qm5h72ITt+E6U0l6qXGdVBTj0XhQVNnGjnZTGzU7IacIw1a/03qVHJ 23 | 11S9VLqV2GEOzBKpB10/lwhRZuv/Qj7obwSqdVCzMNb7t5LHqG0MxOF7BeYELXIq 24 | TEKFfWkZytXCAnmC9hk9RtzUZ/lryD1UgCHZ16gPtmPdFV7fuN8FBNrbaQCldz6V 25 | 6HVDjsPVxPmVYswV8qInG8kJUpm48s9PAWfgi4HCGmJgn+Irbed2tlRf73jxyCgX 26 | 0QIDAQAB 27 | -----END PUBLIC KEY----- 28 | """ 29 | 30 | PEM_PUBLIC_ED25519_INVALID = b""" 31 | -----BEGIN PUBLIC KEY----- 32 | MCowBQYDK2VwAyEAhRk96LXVjEtq8yI1I5Lv0OHiGvgJKfU0a4SweOe0= 33 | -----END PUBLIC KEY----- 34 | """ 35 | 36 | OPENSSH_RSA = b"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQChfejJdi6Jbg4ealsjfC8Jwy3ucwU7PcLWDEVhEi+rvgLRmWhIbK1Tt8lOGx2JECu6zymbFpYSH7cacUqpZfp/Bm4LMtFLqxXqeXymsGmH5mAJYqd0jKZtk0IM8RAvbn9iUvWtmqYXDcE734+dhvsfPEu8LDP251TIskslbj8XIKwQd4q1ervNmhG7o6culFSTltsLwDQ5LdopRfp2cu5i3umNXKBpbYcYDfa4YASmTra/rF+cp9YMXQkTTpsGBRn9wOnJmxRpFEdJ0QDBDqL4zAHkY0Fc4GJufl/4HoYmkolYxzkiYku6wd8bDMcU+o4XZ/78eNoYLPrjCHHy0ytPtFDZMuYB+e8DLGkVp3lNGfV+BRX+s/bexrBRLZoA9U2B7YHq7BOaZs8VRFehU/q0AICM0AOqKHFX3dJPKtEEUb4wmeFS/MoZQm2DXHIhkOA64A+ltdklGgHEjy8daQBvjJ0yIx5IfPMGFpZgk8/ETRcqHTEmmbU1ri6CevQrM7PFCGnmk3btFYUDUHTgykaTr9IA2W+yTMLwKKXBpJlr8lA4oRQpaNpdkuwUY9ivWtTycpl0v5YwLFYsJPcFQPJD31G8AXXBp58K/0YXlt2SuA+kg4QAlFHmJdOAfs8LeQLD01fWhlIWFJlLRS1NHKOOvWKT8YM8kx76I6Ck861Dxw== crgwbr@foo" # NOQA 37 | 38 | OPENSSH_ED25519 = b"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDNkE30ChofcWQbPLrWhR+7uJkwEtRO2UCI2WxRiRpU3 crgwbr@foo" 39 | 40 | OPENSSH_RSA_INVALID = b"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQChfejJLRmWhIbK1Tt8lOGx2JECu6zymbFpYSH7cacUqpZfp/Bm4LMtFLqxXqeXymsGmH5mAJYqd0jKZtk0IM8RAvbn9iUvWtmqYXDcE734+dhvsfPEu8LDP251TIskslbj8XIKwQd4q1ervNmhG7o6culFSTltsLwDQ5LdopRfp2cu5i3umNXKBpbYcYDfa4YASmTra/rF+cp9YMXQkTTpsGBRn9wOnJmxRpFEdJ0QDBDqL4zAHkY0Fc4GJufl/4HoYmkolYxzkiYku6wd8bDMcU+o4XZ/78eNoYLPrjCHHy0ytPtFDZMuYB+e8DLGkVp3lNGfV+BRX+s/bexrBRLZoA9U2B7YHq7BOaZs8VRFehU/q0AICM0AOqKHFX3dJPKtEEUb4wmeFS/MoZQm2DXHIhkOA64A+ltdklGgHEjy8daQBvjJ0yIx5IfPMGFpZgk8/ETRcqHTEmmbU1ri6CevQrM7PFCGnmk3btFYUDUHTgykaTr9IA2W+yTMLwKKXBpJlr8lA4oRQpaNpdkuwUY9ivWtTycpl0v5YwLFYsJPcFQPJD31G8AXXBp58K/0YXlt2SuA+kg4QAlFHmJdOAfs8LeQLD01fWhlIWFJlLRS1NHKOOvWKT8YM8kx76I6Ck861Dxw== crgwbr@foo" # NOQA 41 | 42 | OPENSSH_ED25519_INVALID = b"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDNkE30ChohR+7uJkwEtRO2UCI2WxRiRpU3 crgwbr@foo" 43 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/tests/fixtures/dummy_rsa.privkey: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCh3FtGHks62gHd 3 | KF/oreZGfswTsOijlCbmHvYhO34TpTSXqpcZ1UFOPReFBU2caOdlMbNTshpwjDVr 4 | /TepUcl9xzQqLuKDthI8wyXRZKnSbTzRiWwJn72D5YboOuCOkZBTvJoGE2wq1HkM 5 | /bRubzjXVL1UupXYYQ7MEqkHXT+XCFFm6/9CPuhvBKp1ULMw1vu3kseobQzE4XsF 6 | 5gQtcipMQoV9aRnK1cICeYL2GT1G3NRn+WvIPVSAIdnXqA+2Y90VXt+43wUE2ttp 7 | AKV3PpXodUOOw9XE+ZVizBXyoicbyQlSmbjyz08BZ+CLgcIaYmCf4itt53a2VF/v 8 | ePHIKBfRAgMBAAECggEBAIUeIGbzhTWalEvZ578KPkeeAqLzLPFTaAZ8UjqUniT0 9 | CuPtZaXWUIZTEiPRb7oCQMRl8rET2lDTzx/IOl3jqM3r5ggHVT2zoR4d9N1YZ55r 10 | Psipt5PWr1tpiuE1gvdd2hA0HYx/rscuxXucsCbfDCV0SN4FMjWp5SyK8D7hPuor 11 | ms6EJ+JgNWGJvVKbnBXrtfZtBaTW4BuIu8f2WxuHG3ngQl4jRR8Jnh5JniMROxy8 12 | MMx3/NmiU3hfhnhU2l1tQTn1t9cvciOF+DrZjdv30h1NPbexL+UczXFWb2aAYMtC 13 | 89iNadfqPdMIZF86Xg1dgLaYGOUa7K1xSCuspvUI2lECgYEA1tV9fwSgNcWqBwS5 14 | TisaqErVohBGqWB+74NOq6SfV9zM226QtrrU8yNlAhxQfwjDtqnAon3NtvZENula 15 | dsev99JLjtJFfV7jsqgz/ybEJ3tkEM/EiQU+eGfp58Dq3WpZb7a2PA/hDnRXsJDp 16 | w7dq/fTzkAmlG02CxpVDCc9R2m0CgYEAwOBPD6+zYQCguXxk/3COQBVpjtFzouqZ 17 | v5Oy3WVxSw/KCRO7/hMVCAAWI9JCTd3a44m8F8e03UoXs4u1eR49H5OufLilT+lf 18 | ImdbAvQMHb5cLPr4oh884ANfJih71xTmJnAJ8stX+HSGkKxs9yxVYoZWTGi/mw6z 19 | FttOYzAx1HUCgYBR9GWIlBIuETbYsJOkX0svEkVHKuBZ8wbZhgT387gZw5Ce0SIB 20 | o2pjSohY8sY+f/BxeXaURlu4xV+mdwTctTbK2n2agVqjBhTk7cfQOVCxIyA8TZZT 21 | Ex4Ovs17bJvsVYrC1DfW19PqOLXPFKko0YrOUKittRA4RyxxZzWIw38dTQKBgCEu 22 | tgth0/+NRxmCQDH+IEsAJA/xEu7lY5wlAfG7ARnD1qNnJMGacNTWhviUtNmGoKDi 23 | 0lxY/FHR7G/0Sj1TKXrkQnGspqwv3zEhDPReHjODy4Hlj578ttFnYxhCgMPJEatt 24 | PRjrSPAyw+/h6kE//FSd/fzZTJWVmtQE2OCRqxD9AoGASiN9htvqvXldVDMoR2F2 25 | F+KRA2lXYg78Rg+dpDYLJBk6t8c9e7/xLJATgZy3tLC5YQcpCkrfoCcztdmOiiVt 26 | Q55GCaDNUu1Ttwlu/6yocwYPPS4pP2/qUUDzzBoCEg+PfXSOAsLrGHQ3YLoqbw/H 27 | DxwoXAVLIrFyhFJdklMTnZs= 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/tests/fixtures/dummy_rsa.pub: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAodxbRh5LOtoB3Shf6K3m 3 | Rn7ME7Doo5Qm5h72ITt+E6U0l6qXGdVBTj0XhQVNnGjnZTGzU7IacIw1a/03qVHJ 4 | fcc0Ki7ig7YSPMMl0WSp0m080YlsCZ+9g+WG6DrgjpGQU7yaBhNsKtR5DP20bm84 5 | 11S9VLqV2GEOzBKpB10/lwhRZuv/Qj7obwSqdVCzMNb7t5LHqG0MxOF7BeYELXIq 6 | TEKFfWkZytXCAnmC9hk9RtzUZ/lryD1UgCHZ16gPtmPdFV7fuN8FBNrbaQCldz6V 7 | 6HVDjsPVxPmVYswV8qInG8kJUpm48s9PAWfgi4HCGmJgn+Irbed2tlRf73jxyCgX 8 | 0QIDAQAB 9 | -----END PUBLIC KEY----- 10 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/tests/fixtures/dummy_rsa_encrypted.privkey: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | Proc-Type: 4,ENCRYPTED 3 | DEK-Info: AES-128-CBC,EEA1BAAA5D0675AD65FCB607D509C63A 4 | 5 | OebuWrsJoKXYUqQSfUoMcQ1YAvBdwS1nuELw7jrM6cpcsBoHlyMGcUMk7xBiT+IF 6 | L5l0lyh+7m5mceZdLAQL7sW4N1iv/QcqMggbydYOZ/m+xDXUv2mmf3M5PvwhVp4d 7 | 7e/oA/JnQPa2sdI4G1L0tCTlBc3v1TMvtUr9YMk4VQsTRvtPfiiSTn3otZRgGRku 8 | oyRHKGuF2cSofeLb6ik4G3GQPC5fO05iZ9nqXlOtkZSd5a1UUwGP1cqmO0SdNPms 9 | rBxSCsXMs5mSy5/AckDx3tksLLgq9PkDbcH9cAaQIWIz2m6F3Z9zlnsjqbO8kfDr 10 | GN6tV6mtpud7kPqUoNCXO+DoXcGLJlhj3lc+epZNo3mbqb/KP6fszG/95UBmUOBW 11 | F1RNrTm8C3VTZdNMk+wwFBVorUxw/vpCk+5ArQXp8HrGJqt5iM4lAV+T3OxNSt3H 12 | Hl4UGM21PepBWRtIhSQavzEWVCNHGx1P7YmGYlP8zYIV6HO4Die1n9275I+NwKZ7 13 | Jzq+rWsrBBJjsFjVOcIqnkocNjO0xlEQEbHQRLfrkkcNrBEewhpKnPAM/m7cKs87 14 | 7qqd9wh0dmKiBGRkfvQ0jJ7bsXY5PvhKJUIwhcXdSOW/A3DfkEAXIqonoXHyBObb 15 | Z6B2fXsksacTJNg+8UpQ7jcTH2QFGxdFQ0M+AFUDyuZzN7DqoOnKycd/QGi9r4V/ 16 | wjJbBLLILjc7CWWAO6Ljnx+zSBIGa27iNMFhlaR8OxJhPvWaPU9LaH+GO7NHAyU9 17 | +N2jHrnkKizlAIODfiDUou5/rpkB1gSCdskV2F9REXbeNiWMsxuw1JSOFA1nCCmF 18 | MLSU2vU2/kexzxMkHnpLPexvxCbrcok/94OAPpVS2L8/c+3gmhKiCvyG6KIL8bkx 19 | hk2AfsywjGO6quM13IoX8AWAYA/oVVgiK9WzBIjZiNj0Ot5ikx7hJiFyJKDEDJ5h 20 | dXtRnccGCbRwOgtP+cnFqWKeb1siLMm2lUMMb2BZmzRi822jJ/JlNC1RVw/KIyX2 21 | US4SACF6sU+htKX6J25ZQrHnk4aunxcDuglvpz+yCeODKj0jd4MhlLckPVO2MpmT 22 | yEamgpJHplh2ucjQJBF3sKVtn6GSDVu7Qq2l8n0Oq7jyvqu/9p95Li/V1dKRD7a0 23 | XuPLSCMMg46Ma0+BF9nCT92j5VDafPkAKGoxkYDT7w3Md9VRTAAN0QCn1g/NN01O 24 | xtpMaFosPuCJcXs4KkLaczOjG708dX8jQn84q8FUQFPKrxlC2YhnFcwx1TW/NpS2 25 | cuIt8zCqK91fEt8oWhP3muMeHrGNYtzF9jPnFPLEaBLOwT5jVvjl7fBkZU7yJ6Ue 26 | k9GrmqAb3VlJaLwAUB3kPSXff35kb+kiAdLh4tU2fNElkiKUvoh84dpxHrvLT5js 27 | FxDHvf4uJAs2D1YoxoCb+n1AyzJraFRtybOr1t9O0TIHy4kaCsD+D6GoupzrGBE1 28 | uzlE83bVv9vKBSUU7R+37kRr11PhT8NnARd9ioLZAGuXxxFo8XzyhjhE18W/Cipv 29 | 0gRRFQl0AMYWTCEsRQ63U9BS3QpHIqpdp9H8/QlCE3I3EaA/e3YTU6Np30mc5bCw 30 | -----END RSA PRIVATE KEY----- 31 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/tests/fixtures/dummy_rsa_encrypted.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC2PliuH/JOKR6UlNd/Hf9D9WNHfAoArrL+vePc1G1sPouCnbB5G3DQ1MoCFboNRRo71ZKYzCTJMqmw/8kj4y8SVK/fg1mvp/P+Yl0o9l4Of96r+1dpAi0sD+zgHI3OPKWxRqECERY0cCOVO4/hj29hQykDP1Swfn9MCZQoiPNFsg2gDG8Hr9g1ZX8Lsenc7svl5TjrXJhogGApJx0wNM0VOxMQVq3Da8uwDKcqviPxNJuMkpXvbpPkUehS3+10zsU5pv0FHYBbpzdade7V+35w0zGkVvhnaG+aESN3OnL6UnlkoGZvY31VynpBKOCU0ZagCxDgD6lg+fs7C5UYxDib crgwbr@Galactica.local 2 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/tests/test_admin.py: -------------------------------------------------------------------------------- 1 | from .. import admin # NOQA 2 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/tests/test_commands.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | 3 | from django.core.management import call_command 4 | from django.test import TestCase 5 | 6 | 7 | class ManagementCommandTest(TestCase): 8 | def test_generate_new_rsa_key(self): 9 | stdout = StringIO() 10 | stderr = StringIO() 11 | call_command("generate_key_pair", stdout=stdout, stderr=stderr) 12 | out = stdout.getvalue() 13 | self.assertIn("-----BEGIN PRIVATE KEY-----\n", out) 14 | self.assertIn("-----END PRIVATE KEY-----\n", out) 15 | self.assertIn("-----BEGIN PUBLIC KEY-----\n", out) 16 | self.assertIn("-----END PUBLIC KEY-----\n", out) 17 | self.assertTrue(len(out) > 2000) 18 | self.assertTrue(len(out) < 3000) 19 | 20 | def test_generate_new_ed25519_key(self): 21 | stdout = StringIO() 22 | stderr = StringIO() 23 | call_command( 24 | "generate_key_pair", keytype="Ed25519", stdout=stdout, stderr=stderr 25 | ) 26 | out = stdout.getvalue() 27 | self.assertIn("-----BEGIN PRIVATE KEY-----\n", out) 28 | self.assertIn("-----END PRIVATE KEY-----\n", out) 29 | self.assertIn("-----BEGIN PUBLIC KEY-----\n", out) 30 | self.assertIn("-----END PUBLIC KEY-----\n", out) 31 | self.assertTrue(len(out) > 200) 32 | self.assertTrue(len(out) < 300) 33 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/tests/test_keys.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | import os.path 3 | 4 | from django.test import TestCase 5 | 6 | from .. import keys 7 | from . import data 8 | 9 | 10 | class PublicKeyTest(TestCase): 11 | def test_load_pem_rsa(self): 12 | exc, key = keys.PublicKey.load_serialized_public_key(data.PEM_PUBLIC_RSA) 13 | self.assertIsNone(exc) 14 | self.assertIsInstance(key, keys.RSAPublicKey) 15 | 16 | def test_load_pem_ed25519(self): 17 | exc, key = keys.PublicKey.load_serialized_public_key(data.PEM_PUBLIC_ED25519) 18 | self.assertIsNone(exc) 19 | self.assertIsInstance(key, keys.Ed25519PublicKey) 20 | 21 | def test_load_openssh_rsa(self): 22 | exc, key = keys.PublicKey.load_serialized_public_key(data.OPENSSH_RSA) 23 | self.assertIsNone(exc) 24 | self.assertIsInstance(key, keys.RSAPublicKey) 25 | 26 | def test_load_openssh_ed25519(self): 27 | exc, key = keys.PublicKey.load_serialized_public_key(data.OPENSSH_ED25519) 28 | self.assertIsNone(exc) 29 | self.assertIsInstance(key, keys.Ed25519PublicKey) 30 | 31 | def test_load_invalid_pem_rsa(self): 32 | exc, key = keys.PublicKey.load_serialized_public_key( 33 | data.PEM_PUBLIC_RSA_INVALID 34 | ) 35 | self.assertIsInstance(exc, Exception) 36 | self.assertIsNone(key) 37 | 38 | def test_load_invalid_pem_ed25519(self): 39 | exc, key = keys.PublicKey.load_serialized_public_key( 40 | data.PEM_PUBLIC_ED25519_INVALID 41 | ) 42 | self.assertIsInstance(exc, Exception) 43 | self.assertIsNone(key) 44 | 45 | def test_load_invalid_openssh_rsa(self): 46 | exc, key = keys.PublicKey.load_serialized_public_key(data.OPENSSH_RSA_INVALID) 47 | self.assertIsInstance(exc, Exception) 48 | self.assertIsNone(key) 49 | 50 | def test_load_invalid_openssh_ed25519(self): 51 | exc, key = keys.PublicKey.load_serialized_public_key( 52 | data.OPENSSH_ED25519_INVALID 53 | ) 54 | self.assertIsInstance(exc, Exception) 55 | self.assertIsNone(key) 56 | 57 | def test_rsa_as_pem(self): 58 | exc, key = keys.PublicKey.load_serialized_public_key(data.PEM_PUBLIC_RSA) 59 | self.assertIsNone(exc) 60 | pem = key.as_pem.decode().strip().split("\n") 61 | self.assertEqual(pem[0], "-----BEGIN PUBLIC KEY-----") 62 | self.assertEqual(pem[-1], "-----END PUBLIC KEY-----") 63 | 64 | def test_ed25519_as_pem(self): 65 | exc, key = keys.PublicKey.load_serialized_public_key(data.PEM_PUBLIC_RSA) 66 | self.assertIsNone(exc) 67 | pem = key.as_pem.decode().strip().split("\n") 68 | self.assertEqual(pem[0], "-----BEGIN PUBLIC KEY-----") 69 | self.assertEqual(pem[-1], "-----END PUBLIC KEY-----") 70 | 71 | def test_rsa_fingerprint(self): 72 | exc, key = keys.PublicKey.load_serialized_public_key(data.PEM_PUBLIC_RSA) 73 | self.assertIsNone(exc) 74 | self.assertEqual( 75 | key.fingerprint, 76 | "53c5b68c5ecba3e25df3f8326de6c0b0befb67e9217651a2f40e388f6567f056", 77 | ) 78 | 79 | def test_ed25519_fingerprint(self): 80 | exc, key = keys.PublicKey.load_serialized_public_key(data.PEM_PUBLIC_ED25519) 81 | self.assertIsNone(exc) 82 | self.assertEqual( 83 | key.fingerprint, 84 | "cb10cd75c2eacf7aa2b5195bef9838cccd9d2ae4938601178808cb881b68ec72", 85 | ) 86 | 87 | def test_rsa_allowed_algorithms(self): 88 | exc, key = keys.PublicKey.load_serialized_public_key(data.PEM_PUBLIC_RSA) 89 | self.assertIsNone(exc) 90 | self.assertEqual( 91 | key.allowed_algorithms, 92 | [ 93 | "RS512", 94 | "RS384", 95 | "RS256", 96 | ], 97 | ) 98 | 99 | def test_ed25519_allowed_algorithms(self): 100 | exc, key = keys.PublicKey.load_serialized_public_key(data.PEM_PUBLIC_ED25519) 101 | self.assertIsNone(exc) 102 | self.assertEqual( 103 | key.allowed_algorithms, 104 | [ 105 | "EdDSA", 106 | ], 107 | ) 108 | 109 | def test_unknown_key_type(self): 110 | with self.assertRaises(TypeError): 111 | keys.PublicKey.from_cryptography_pubkey(mock.MagicMock()) 112 | 113 | 114 | class PrivateKeyTest(TestCase): 115 | def test_generate_rsa(self): 116 | private = keys.RSAPrivateKey.generate() 117 | self.assertIsInstance(private, keys.RSAPrivateKey) 118 | self.assertIsInstance(private.public_key, keys.RSAPublicKey) 119 | 120 | def test_generate_ed25519(self): 121 | private = keys.Ed25519PrivateKey.generate() 122 | self.assertIsInstance(private, keys.Ed25519PrivateKey) 123 | self.assertIsInstance(private.public_key, keys.Ed25519PublicKey) 124 | 125 | def test_load_rsa_from_file(self): 126 | base = os.path.dirname(__file__) 127 | filepath = os.path.join(base, "fixtures/dummy_rsa.privkey") 128 | private = keys.RSAPrivateKey.load_pem_from_file(filepath) 129 | self.assertIsInstance(private, keys.RSAPrivateKey) 130 | 131 | def test_load_rsa_from_file_encrypted(self): 132 | base = os.path.dirname(__file__) 133 | filepath = os.path.join(base, "fixtures/dummy_rsa_encrypted.privkey") 134 | private = keys.RSAPrivateKey.load_pem_from_file(filepath, password=b"password") 135 | self.assertIsInstance(private, keys.RSAPrivateKey) 136 | 137 | def test_load_rsa(self): 138 | priv1 = keys.RSAPrivateKey.generate() 139 | priv2 = keys.RSAPrivateKey.load_pem(priv1.as_pem) 140 | self.assertEqual(priv2.as_pem, priv1.as_pem) 141 | 142 | def test_load_ed25519(self): 143 | priv1 = keys.Ed25519PrivateKey.generate() 144 | priv2 = keys.Ed25519PrivateKey.load_pem(priv1.as_pem) 145 | self.assertEqual(priv2.as_pem, priv1.as_pem) 146 | 147 | def test_rsa_as_pem(self): 148 | private = keys.RSAPrivateKey.generate() 149 | pem = private.as_pem.decode().strip().split("\n") 150 | self.assertEqual(pem[0], "-----BEGIN PRIVATE KEY-----") 151 | self.assertEqual(pem[-1], "-----END PRIVATE KEY-----") 152 | 153 | def test_ed25519_as_pem(self): 154 | private = keys.Ed25519PrivateKey.generate() 155 | pem = private.as_pem.decode().strip().split("\n") 156 | self.assertEqual(pem[0], "-----BEGIN PRIVATE KEY-----") 157 | self.assertEqual(pem[-1], "-----END PRIVATE KEY-----") 158 | 159 | def test_unknown_key_type(self): 160 | with self.assertRaises(TypeError): 161 | keys.PrivateKey.from_cryptography_privkey(mock.MagicMock()) 162 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from django.contrib.auth.models import User 4 | from django.test import RequestFactory, TestCase 5 | 6 | from ..keys import Ed25519PrivateKey, RSAPrivateKey 7 | from ..middleware import JWTAuthMiddleware 8 | from ..models import JWKSEndpointTrust, PublicKey 9 | from ..tokens import Token 10 | 11 | 12 | class BaseMiddlewareTest(TestCase): 13 | def assertNotLoggedIn(self, request): 14 | self.assertEqual(getattr(request, "user", None), None) 15 | 16 | def assertLoggedIn(self, request, public_key=None): 17 | if public_key: 18 | public_key.refresh_from_db() 19 | self.assertIsNotNone(public_key.last_used_on) 20 | self.assertEqual(getattr(request, "user", None), self.user) 21 | 22 | 23 | class MiddlewareTest(BaseMiddlewareTest): 24 | def setUp(self): 25 | self.rfactory = RequestFactory() 26 | self.user = User.objects.create_user(username="foo") 27 | self.user2 = User.objects.create_user(username="bar") 28 | 29 | self.key_ed25519 = Ed25519PrivateKey.generate() 30 | self.key_rsa = RSAPrivateKey.generate() 31 | 32 | self.user_key_ed25519 = PublicKey.objects.create( 33 | user=self.user, key=self.key_ed25519.public_key.as_pem.decode() 34 | ) 35 | self.user_key_rsa = PublicKey.objects.create( 36 | user=self.user, key=self.key_rsa.public_key.as_pem.decode() 37 | ) 38 | 39 | self.next_middleware = mock.MagicMock() 40 | self.run_middleware = JWTAuthMiddleware(self.next_middleware) 41 | 42 | def test_no_auth_header(self): 43 | request = self.rfactory.get("/") 44 | self.assertNotLoggedIn(request) 45 | self.run_middleware(request) 46 | self.assertNotLoggedIn(request) 47 | self.assertEqual(self.next_middleware.call_count, 1) 48 | 49 | def test_auth_header_missing_type(self): 50 | request = self.rfactory.get("/", HTTP_AUTHORIZATION="Fooopbar") 51 | self.assertNotLoggedIn(request) 52 | self.run_middleware(request) 53 | self.assertNotLoggedIn(request) 54 | self.assertEqual(self.next_middleware.call_count, 1) 55 | 56 | def test_auth_header_not_jwt_type(self): 57 | request = self.rfactory.get("/", HTTP_AUTHORIZATION="Bearer foobar") 58 | self.assertNotLoggedIn(request) 59 | self.run_middleware(request) 60 | self.assertNotLoggedIn(request) 61 | self.assertEqual(self.next_middleware.call_count, 1) 62 | 63 | def test_header_jwt_missing_username(self): 64 | header = Token("").create_auth_header(self.key_rsa) 65 | request = self.rfactory.get("/", HTTP_AUTHORIZATION=header) 66 | self.assertNotLoggedIn(request) 67 | self.run_middleware(request) 68 | self.assertNotLoggedIn(request) 69 | self.assertEqual(self.next_middleware.call_count, 1) 70 | 71 | def test_header_jwt_claimed_username_doesnt_exist(self): 72 | header = Token("rusty").create_auth_header(self.key_rsa) 73 | request = self.rfactory.get("/", HTTP_AUTHORIZATION=header) 74 | self.assertNotLoggedIn(request) 75 | self.run_middleware(request) 76 | self.assertNotLoggedIn(request) 77 | self.assertEqual(self.next_middleware.call_count, 1) 78 | 79 | def test_authenticate_request_rsa_valid(self): 80 | header = Token(self.user.username).create_auth_header(self.key_rsa) 81 | request = self.rfactory.get("/", HTTP_AUTHORIZATION=header) 82 | self.assertNotLoggedIn(request) 83 | self.run_middleware(request) 84 | self.assertLoggedIn(request, self.user_key_rsa) 85 | self.assertEqual(self.next_middleware.call_count, 1) 86 | 87 | def test_authenticate_request_rsa_unregistered_key(self): 88 | # Assign the pub keys to user 2 89 | self.user_key_ed25519.user = self.user2 90 | self.user_key_ed25519.save() 91 | self.user_key_rsa.user = self.user2 92 | self.user_key_rsa.save() 93 | # Try to use user2's key to login as user1 94 | header = Token(self.user.username).create_auth_header(self.key_rsa) 95 | request = self.rfactory.get("/", HTTP_AUTHORIZATION=header) 96 | self.assertNotLoggedIn(request) 97 | self.run_middleware(request) 98 | self.assertNotLoggedIn(request) 99 | self.assertEqual(self.next_middleware.call_count, 1) 100 | 101 | def test_authenticate_request_ed25519_valid(self): 102 | header = Token(self.user.username).create_auth_header(self.key_ed25519) 103 | request = self.rfactory.get("/", HTTP_AUTHORIZATION=header) 104 | self.assertNotLoggedIn(request) 105 | self.run_middleware(request) 106 | self.assertLoggedIn(request, self.user_key_ed25519) 107 | self.assertEqual(self.next_middleware.call_count, 1) 108 | 109 | def test_missing_data(self): 110 | header = Token(self.user.username, timestamp=0).create_auth_header( 111 | self.key_ed25519 112 | ) 113 | request = self.rfactory.get("/", HTTP_AUTHORIZATION=header) 114 | self.assertNotLoggedIn(request) 115 | self.run_middleware(request) 116 | self.assertNotLoggedIn(request) 117 | self.assertEqual(self.next_middleware.call_count, 1) 118 | 119 | def test_cant_reuse_nonce(self): 120 | header = Token(self.user.username).create_auth_header(self.key_ed25519) 121 | # First use works 122 | request1 = self.rfactory.get("/", HTTP_AUTHORIZATION=header) 123 | self.assertNotLoggedIn(request1) 124 | self.run_middleware(request1) 125 | self.assertLoggedIn(request1, self.user_key_ed25519) 126 | self.assertEqual(self.next_middleware.call_count, 1) 127 | # Second use doesn't 128 | request2 = self.rfactory.get("/", HTTP_AUTHORIZATION=header) 129 | self.assertNotLoggedIn(request2) 130 | self.run_middleware(request2) 131 | self.assertNotLoggedIn(request2) 132 | self.assertEqual(self.next_middleware.call_count, 2) 133 | 134 | 135 | class MiddlewareJWKSTest(BaseMiddlewareTest): 136 | def setUp(self): 137 | self.rfactory = RequestFactory() 138 | self.user = User.objects.create_user(username="foo") 139 | 140 | self.key_ed25519 = Ed25519PrivateKey.generate() 141 | self.key_rsa = RSAPrivateKey.generate() 142 | 143 | self.next_middleware = mock.MagicMock() 144 | self.run_middleware = JWTAuthMiddleware(self.next_middleware) 145 | 146 | @mock.patch("asymmetric_jwt_auth.models.PyJWKClient.fetch_data") 147 | def test_authenticate_request_rsa(self, mock_fetch_data): 148 | mock_fetch_data.return_value = { 149 | "keys": [ 150 | self.key_rsa.public_key.as_jwk, 151 | ], 152 | } 153 | JWKSEndpointTrust.objects.create(user=self.user, jwks_url="") 154 | header = Token(self.user.username).create_auth_header(self.key_rsa) 155 | request = self.rfactory.get("/", HTTP_AUTHORIZATION=header) 156 | self.assertNotLoggedIn(request) 157 | self.run_middleware(request) 158 | self.assertLoggedIn(request) 159 | self.assertEqual(self.next_middleware.call_count, 1) 160 | 161 | @mock.patch("asymmetric_jwt_auth.models.PyJWKClient.fetch_data") 162 | def test_no_matching_key(self, mock_fetch_data): 163 | # Change the key ID of the JWKS response so it doesn't match the kid in the header JWT 164 | jwk = self.key_rsa.public_key.as_jwk 165 | jwk["kid"] = "foobar" 166 | mock_fetch_data.return_value = { 167 | "keys": [ 168 | jwk, 169 | ], 170 | } 171 | JWKSEndpointTrust.objects.create(user=self.user, jwks_url="") 172 | header = Token(self.user.username).create_auth_header(self.key_rsa) 173 | request = self.rfactory.get("/", HTTP_AUTHORIZATION=header) 174 | self.assertNotLoggedIn(request) 175 | self.run_middleware(request) 176 | self.assertNotLoggedIn(request) 177 | self.assertEqual(self.next_middleware.call_count, 1) 178 | 179 | @mock.patch("asymmetric_jwt_auth.models.PyJWKClient.fetch_data") 180 | def test_header_jwt_claimed_username_doesnt_exist(self, mock_fetch_data): 181 | mock_fetch_data.return_value = { 182 | "keys": [ 183 | self.key_rsa.public_key.as_jwk, 184 | ], 185 | } 186 | JWKSEndpointTrust.objects.create(user=self.user, jwks_url="") 187 | header = Token("rusty").create_auth_header(self.key_rsa) 188 | request = self.rfactory.get("/", HTTP_AUTHORIZATION=header) 189 | self.assertNotLoggedIn(request) 190 | self.run_middleware(request) 191 | self.assertNotLoggedIn(request) 192 | self.assertEqual(self.next_middleware.call_count, 1) 193 | 194 | @mock.patch("asymmetric_jwt_auth.models.PyJWKClient.fetch_data") 195 | def test_missing_data(self, mock_fetch_data): 196 | mock_fetch_data.return_value = { 197 | "keys": [ 198 | self.key_rsa.public_key.as_jwk, 199 | ], 200 | } 201 | JWKSEndpointTrust.objects.create(user=self.user, jwks_url="") 202 | header = Token(self.user.username, timestamp=0).create_auth_header(self.key_rsa) 203 | request = self.rfactory.get("/", HTTP_AUTHORIZATION=header) 204 | self.assertNotLoggedIn(request) 205 | self.run_middleware(request) 206 | self.assertNotLoggedIn(request) 207 | self.assertEqual(self.next_middleware.call_count, 1) 208 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/tests/test_models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.core.exceptions import ValidationError 3 | from django.test import TestCase 4 | from django.utils import timezone 5 | import jwt 6 | 7 | from .. import keys, models, tokens 8 | from . import data 9 | 10 | 11 | class ValidatePublicKeyTest(TestCase): 12 | def test_valid_rsa_pem(self): 13 | models.validate_public_key(data.PEM_PUBLIC_RSA.decode()) 14 | 15 | def test_valid_ed25519_pem(self): 16 | models.validate_public_key(data.PEM_PUBLIC_ED25519.decode()) 17 | 18 | def test_valid_rsa_openssh(self): 19 | models.validate_public_key(data.OPENSSH_RSA.decode()) 20 | 21 | def test_valid_ed25519_openssh(self): 22 | models.validate_public_key(data.OPENSSH_ED25519.decode()) 23 | 24 | def test_invalid_rsa_pem(self): 25 | with self.assertRaises(ValidationError): 26 | models.validate_public_key(data.PEM_PUBLIC_RSA_INVALID.decode()) 27 | 28 | def test_invalid_ed25519_pem(self): 29 | with self.assertRaises(ValidationError): 30 | models.validate_public_key(data.PEM_PUBLIC_ED25519_INVALID.decode()) 31 | 32 | def test_invalid_rsa_openssh(self): 33 | with self.assertRaises(ValidationError): 34 | models.validate_public_key(data.OPENSSH_RSA_INVALID.decode()) 35 | 36 | def test_invalid_ed25519_openssh(self): 37 | with self.assertRaises(ValidationError): 38 | models.validate_public_key(data.OPENSSH_ED25519_INVALID.decode()) 39 | 40 | 41 | class PublicKeyTest(TestCase): 42 | def setUp(self): 43 | self.user = User.objects.create_user(username="foo") 44 | 45 | def test_extract_comment(self): 46 | pub = models.PublicKey(user=self.user, key=data.OPENSSH_ED25519, comment="") 47 | pub.save() 48 | self.assertEqual(pub.comment, "crgwbr@foo") 49 | 50 | def test_update_last_used_datetime(self): 51 | pub = models.PublicKey(user=self.user, key=data.OPENSSH_ED25519) 52 | pub.save() 53 | self.assertEqual(pub.last_used_on, None) 54 | pub.update_last_used_datetime() 55 | # Check the first 19 digits (year – second precision) of ISO time: 2021-03-03T17:00:24 56 | self.assertEqual( 57 | pub.last_used_on.isoformat()[:19], timezone.now().isoformat()[:19] 58 | ) 59 | 60 | def test_get_key_ed25519(self): 61 | pub = models.PublicKey(user=self.user, key=data.OPENSSH_ED25519) 62 | pub.save() 63 | self.assertIsInstance(pub.get_key(), keys.Ed25519PublicKey) 64 | 65 | def test_get_loaded_key_rsa(self): 66 | pub = models.PublicKey(user=self.user, key=data.OPENSSH_RSA, comment="") 67 | pub.save() 68 | self.assertIsInstance(pub.get_key(), keys.RSAPublicKey) 69 | 70 | def test_get_loaded_key_invalid(self): 71 | pub = models.PublicKey(user=self.user, key=data.OPENSSH_ED25519_INVALID) 72 | pub.save() 73 | with self.assertRaises(ValueError): 74 | pub.get_key() 75 | 76 | 77 | class JWKSEndpointTrustTest(TestCase): 78 | def setUp(self): 79 | self.user = User.objects.create_user(username="foo") 80 | self.jwks = models.JWKSEndpointTrust.objects.create( 81 | user=self.user, 82 | jwks_url="https://dev-87evx9ru.auth0.com/.well-known/jwks.json", 83 | ) 84 | 85 | def test_get_signing_key(self): 86 | untrusted_token = tokens.UntrustedToken( 87 | b"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ik5FRTFRVVJCT1RNNE16STVSa0ZETlRZeE9UVTFNRGcyT0Rnd1EwVXpNVGsxUWpZeVJrUkZRdyJ9.eyJpc3MiOiJodHRwczovL2Rldi04N2V2eDlydS5hdXRoMC5jb20vIiwic3ViIjoiYVc0Q2NhNzl4UmVMV1V6MGFFMkg2a0QwTzNjWEJWdENAY2xpZW50cyIsImF1ZCI6Imh0dHBzOi8vZXhwZW5zZXMtYXBpIiwiaWF0IjoxNTcyMDA2OTU0LCJleHAiOjE1NzIwMDY5NjQsImF6cCI6ImFXNENjYTc5eFJlTFdVejBhRTJINmtEME8zY1hCVnRDIiwiZ3R5IjoiY2xpZW50LWNyZWRlbnRpYWxzIn0.PUxE7xn52aTCohGiWoSdMBZGiYAHwE5FYie0Y1qUT68IHSTXwXVd6hn02HTah6epvHHVKA2FqcFZ4GGv5VTHEvYpeggiiZMgbxFrmTEY0csL6VNkX1eaJGcuehwQCRBKRLL3zKmA5IKGy5GeUnIbpPHLHDxr-GXvgFzsdsyWlVQvPX2xjeaQ217r2PtxDeqjlf66UYl6oY6AqNS8DH3iryCvIfCcybRZkc_hdy-6ZMoKT6Piijvk_aXdm7-QQqKJFHLuEqrVSOuBqqiNfVrG27QzAPuPOxvfXTVLXL2jek5meH6n-VWgrBdoMFH93QEszEDowDAEhQPHVs0xj7SIzA" # NOQA 88 | ) 89 | signing_key = self.jwks.get_signing_key(untrusted_token) 90 | self.assertIsInstance(signing_key, keys.RSAPublicKey) 91 | data = jwt.decode( 92 | untrusted_token.token, 93 | signing_key.as_pem, 94 | algorithms=["RS256"], 95 | audience="https://expenses-api", 96 | options={"verify_exp": False}, 97 | ) 98 | self.assertEqual( 99 | data, 100 | { 101 | "iss": "https://dev-87evx9ru.auth0.com/", 102 | "sub": "aW4Cca79xReLWUz0aE2H6kD0O3cXBVtC@clients", 103 | "aud": "https://expenses-api", 104 | "iat": 1572006954, 105 | "exp": 1572006964, 106 | "azp": "aW4Cca79xReLWUz0aE2H6kD0O3cXBVtC", 107 | "gty": "client-credentials", 108 | }, 109 | ) 110 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/tests/test_settings.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase, override_settings 2 | 3 | from .. import get_setting 4 | 5 | 6 | class SettingsTest(TestCase): 7 | def test_get_settings_default(self): 8 | self.assertEqual(get_setting("AUTH_METHOD"), "JWT") 9 | self.assertEqual(get_setting("TIMESTAMP_TOLERANCE"), 20) 10 | 11 | @override_settings(ASYMMETRIC_JWT_AUTH=dict(TIMESTAMP_TOLERANCE=30)) 12 | def test_get_setting_overridden(self): 13 | self.assertEqual(get_setting("AUTH_METHOD"), "JWT") 14 | self.assertEqual(get_setting("TIMESTAMP_TOLERANCE"), 30) 15 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/tests/test_token.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from unittest.mock import patch 3 | 4 | from django.contrib.auth.models import User 5 | from django.test import TestCase, override_settings 6 | from freezegun import freeze_time 7 | import jwt 8 | 9 | from .. import keys, tokens 10 | from . import data 11 | 12 | 13 | class TokenToken(TestCase): 14 | def setUp(self): 15 | self.username = "rusty" 16 | self.privkey = keys.Ed25519PrivateKey.generate() 17 | 18 | @patch("time.time") 19 | @patch("secrets.token_urlsafe") 20 | def test_create_auth_header(self, mock_get_nonce, mock_time): 21 | mock_get_nonce.return_value = "yVJ0MVWhqPQ" 22 | mock_time.return_value = 1234000.3 23 | token = tokens.Token(username=self.username) 24 | header = token.create_auth_header(self.privkey) 25 | self.assertTrue(header.startswith("JWT ")) 26 | data = jwt.decode( 27 | jwt=header.split(" ")[1], 28 | key=self.privkey.public_key.as_pem, 29 | algorithms=self.privkey.public_key.allowed_algorithms, 30 | ) 31 | self.assertEqual( 32 | data, 33 | { 34 | "nonce": "yVJ0MVWhqPQ", 35 | "time": 1234000, 36 | "username": "rusty", 37 | }, 38 | ) 39 | 40 | @patch("secrets.token_urlsafe") 41 | def test_create_auth_header_custom_time(self, mock_get_nonce): 42 | mock_get_nonce.return_value = "yVJ0MVWhqPQ" 43 | token = tokens.Token(username=self.username, timestamp=1614974974) 44 | header = token.create_auth_header(self.privkey) 45 | self.assertTrue(header.startswith("JWT ")) 46 | data = jwt.decode( 47 | jwt=header.split(" ")[1], 48 | key=self.privkey.public_key.as_pem, 49 | algorithms=self.privkey.public_key.allowed_algorithms, 50 | ) 51 | self.assertEqual( 52 | data, 53 | { 54 | "nonce": "yVJ0MVWhqPQ", 55 | "time": 1614974974, 56 | "username": "rusty", 57 | }, 58 | ) 59 | 60 | 61 | class UntrustedTokenTest(TestCase): 62 | def setUp(self): 63 | self.username = "rusty" 64 | self.user = User.objects.create(username=self.username) 65 | self.privkey = keys.Ed25519PrivateKey.generate() 66 | self.token = tokens.Token(username=self.username) 67 | self.jwt_value = self.token.sign(self.privkey) 68 | self.untrusted_token = tokens.UntrustedToken(self.jwt_value) 69 | 70 | def test_get_claimed_username(self): 71 | self.assertEqual(self.untrusted_token.get_claimed_username(), self.username) 72 | 73 | def test_verify_valid(self): 74 | token = self.untrusted_token.verify(self.privkey.public_key) 75 | self.assertIsInstance(token, tokens.Token) 76 | self.assertEqual(token.username, self.username) 77 | 78 | def test_verify_key_mismatch(self): 79 | pubkey = keys.PublicKey.load_pem(data.PEM_PUBLIC_RSA) 80 | token = self.untrusted_token.verify(pubkey) 81 | self.assertIsNone(token) 82 | 83 | def test_time_out_of_allowed_range_before(self): 84 | dt = datetime.now() - timedelta(seconds=30) 85 | with freeze_time(dt): 86 | token = self.untrusted_token.verify(self.privkey.public_key) 87 | self.assertIsNone(token) 88 | 89 | def test_time_out_of_allowed_range_after(self): 90 | dt = datetime.now() + timedelta(seconds=30) 91 | with freeze_time(dt): 92 | token = self.untrusted_token.verify(self.privkey.public_key) 93 | self.assertIsNone(token) 94 | 95 | def test_nonce_already_used(self): 96 | token1 = self.untrusted_token.verify(self.privkey.public_key) 97 | self.assertIsInstance(token1, tokens.Token) 98 | self.assertEqual(token1.username, self.username) 99 | # Second attempt fails because nonce was already used 100 | token2 = self.untrusted_token.verify(self.privkey.public_key) 101 | self.assertIsNone(token2) 102 | 103 | @override_settings( 104 | ASYMMETRIC_JWT_AUTH=dict( 105 | NONCE_BACKEND="asymmetric_jwt_auth.nonce.null.NullNonceBackend" 106 | ) 107 | ) 108 | def test_nonce_already_used_null_backend(self): 109 | token1 = self.untrusted_token.verify(self.privkey.public_key) 110 | self.assertIsInstance(token1, tokens.Token) 111 | self.assertEqual(token1.username, self.username) 112 | # Second attempt works becuase null nonce backend doesn't do anything 113 | token2 = self.untrusted_token.verify(self.privkey.public_key) 114 | self.assertIsInstance(token2, tokens.Token) 115 | self.assertEqual(token2.username, self.username) 116 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | 4 | class UtilsTest(TestCase): 5 | pass 6 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/tests/test_views.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.core.cache import cache 4 | from django.test import Client, TestCase, override_settings 5 | from django.urls import reverse 6 | 7 | from ..keys import PublicKey 8 | from . import data 9 | 10 | 11 | class JWKSViewTest(TestCase): 12 | def setUp(self): 13 | cache.clear() 14 | 15 | def test_no_keys(self): 16 | client = Client() 17 | response = client.get(reverse("asymmetric_jwt_auth:jwks")) 18 | self.assertEqual(response.status_code, 200) 19 | self.assertEqual( 20 | json.loads(response.content), 21 | { 22 | "keys": [], 23 | }, 24 | ) 25 | 26 | @override_settings( 27 | ASYMMETRIC_JWT_AUTH=dict( 28 | SIGNING_PUBLIC_KEYS=[data.PEM_PUBLIC_RSA, data.PEM_PUBLIC_RSA], 29 | ) 30 | ) 31 | def test_pem_keys(self): 32 | client = Client() 33 | response = client.get(reverse("asymmetric_jwt_auth:jwks")) 34 | self.assertEqual(response.status_code, 200) 35 | jwk = { 36 | "alg": "RS512", 37 | "e": "AQAB", 38 | "kid": "53c5b68c5ecba3e25df3f8326de6c0b0befb67e9217651a2f40e388f6567f056", 39 | "kty": "RSA", 40 | "n": "odxbRh5LOtoB3Shf6K3mRn7ME7Doo5Qm5h72ITt-E6U0l6qXGdVBTj0XhQVNnGjnZTGzU7IacIw1a_03qVHJfcc0Ki7ig7YSPMMl0WSp0m080YlsCZ-9g-WG6DrgjpGQU7yaBhNsKtR5DP20bm8411S9VLqV2GEOzBKpB10_lwhRZuv_Qj7obwSqdVCzMNb7t5LHqG0MxOF7BeYELXIqTEKFfWkZytXCAnmC9hk9RtzUZ_lryD1UgCHZ16gPtmPdFV7fuN8FBNrbaQCldz6V6HVDjsPVxPmVYswV8qInG8kJUpm48s9PAWfgi4HCGmJgn-Irbed2tlRf73jxyCgX0Q", # NOQA 41 | "use": "sig", 42 | } 43 | self.assertEqual( 44 | json.loads(response.content), 45 | { 46 | "keys": [ 47 | jwk, 48 | jwk, 49 | ], 50 | }, 51 | ) 52 | 53 | @override_settings( 54 | ASYMMETRIC_JWT_AUTH=dict( 55 | SIGNING_PUBLIC_KEYS=[ 56 | PublicKey.load_pem(data.PEM_PUBLIC_RSA), 57 | ], 58 | ) 59 | ) 60 | def test_loaded_keys(self): 61 | client = Client() 62 | response = client.get(reverse("asymmetric_jwt_auth:jwks")) 63 | self.assertEqual(response.status_code, 200) 64 | jwk = { 65 | "alg": "RS512", 66 | "e": "AQAB", 67 | "kid": "53c5b68c5ecba3e25df3f8326de6c0b0befb67e9217651a2f40e388f6567f056", 68 | "kty": "RSA", 69 | "n": "odxbRh5LOtoB3Shf6K3mRn7ME7Doo5Qm5h72ITt-E6U0l6qXGdVBTj0XhQVNnGjnZTGzU7IacIw1a_03qVHJfcc0Ki7ig7YSPMMl0WSp0m080YlsCZ-9g-WG6DrgjpGQU7yaBhNsKtR5DP20bm8411S9VLqV2GEOzBKpB10_lwhRZuv_Qj7obwSqdVCzMNb7t5LHqG0MxOF7BeYELXIqTEKFfWkZytXCAnmC9hk9RtzUZ_lryD1UgCHZ16gPtmPdFV7fuN8FBNrbaQCldz6V6HVDjsPVxPmVYswV8qInG8kJUpm48s9PAWfgi4HCGmJgn-Irbed2tlRf73jxyCgX0Q", # NOQA 70 | "use": "sig", 71 | } 72 | self.assertEqual( 73 | json.loads(response.content), 74 | { 75 | "keys": [ 76 | jwk, 77 | ], 78 | }, 79 | ) 80 | 81 | @override_settings( 82 | ASYMMETRIC_JWT_AUTH=dict( 83 | SIGNING_PUBLIC_KEYS=[ 84 | data.PEM_PUBLIC_RSA_INVALID, 85 | ], 86 | ) 87 | ) 88 | def test_invalid_pem_keys(self): 89 | client = Client() 90 | response = client.get(reverse("asymmetric_jwt_auth:jwks")) 91 | self.assertEqual(response.status_code, 200) 92 | self.assertEqual( 93 | json.loads(response.content), 94 | { 95 | "keys": [], 96 | }, 97 | ) 98 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/tokens.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | 4 | import jwt 5 | 6 | from . import get_setting, keys 7 | from .nonce import get_nonce_backend 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class Token: 13 | """ 14 | Represents a JWT that's either been constructed by our code or has been 15 | verified to be valid. 16 | """ 17 | 18 | username: str 19 | timestamp: int 20 | 21 | def __init__(self, username: str, timestamp: int | None = None): 22 | self.username = username 23 | self.timestamp = int(time.time()) if timestamp is None else timestamp 24 | 25 | def create_auth_header(self, private_key: keys.FacadePrivateKey) -> str: 26 | """ 27 | Create an HTTP Authorization header 28 | """ 29 | 30 | auth_method = get_setting("AUTH_METHOD") 31 | token = self.sign(private_key) 32 | return f"{auth_method} {token}" 33 | 34 | def sign(self, private_key: keys.FacadePrivateKey) -> str: 35 | """ 36 | Create and return signed authentication JWT 37 | """ 38 | public_key = private_key.public_key 39 | algorithm = public_key.allowed_algorithms[0] 40 | nonce = get_nonce_backend().generate_nonce() 41 | kid = public_key.fingerprint 42 | # Build and sign claim data 43 | token_data = { 44 | "username": self.username, 45 | "time": self.timestamp, 46 | "nonce": nonce, 47 | } 48 | headers = { 49 | "kid": kid, 50 | } 51 | token = jwt.encode( 52 | payload=token_data, 53 | key=private_key.as_pem, 54 | algorithm=algorithm, 55 | headers=headers, 56 | ) 57 | return token 58 | 59 | 60 | class UntrustedToken: 61 | """ 62 | Represents a JWT received from user input (and not yet trusted) 63 | """ 64 | 65 | token: str 66 | 67 | def __init__(self, token: str): 68 | self.token = token 69 | 70 | def get_claimed_username(self) -> None | str: 71 | """ 72 | Given a JWT, get the username that it is claiming to be `without verifying that the signature is valid`. 73 | 74 | :param token: JWT claim 75 | :return: Username 76 | """ 77 | unverified_data = jwt.decode(self.token, options={"verify_signature": False}) 78 | username = unverified_data.get("username") 79 | if isinstance(username, str): 80 | return username 81 | return None 82 | 83 | def verify(self, public_key: keys.FacadePublicKey) -> None | Token: 84 | """ 85 | Verify the validity of the given JWT using the given public key. 86 | """ 87 | try: 88 | token_data = jwt.decode( 89 | jwt=self.token, 90 | key=public_key.as_pem.decode(), 91 | algorithms=public_key.allowed_algorithms, 92 | ) 93 | except jwt.InvalidTokenError: 94 | logger.debug("JWT failed verification") 95 | return None 96 | 97 | claimed_username = token_data.get("username") 98 | claimed_time = token_data.get("time", 0) 99 | claimed_nonce = token_data.get("nonce") 100 | 101 | # Ensure fields aren't blank 102 | if not claimed_username or not claimed_time or not claimed_nonce: 103 | return None 104 | 105 | # Ensure time is within acceptable bounds 106 | current_time = time.time() 107 | timestamp_tolerance = get_setting("TIMESTAMP_TOLERANCE") 108 | min_time, max_time = ( 109 | current_time - timestamp_tolerance, 110 | current_time + timestamp_tolerance, 111 | ) 112 | if claimed_time < min_time or claimed_time > max_time: 113 | logger.debug("Claimed time is outside of allowable tolerances") 114 | return None 115 | 116 | # Ensure nonce is unique 117 | nonce_backend = get_nonce_backend() 118 | if not nonce_backend.validate_nonce( 119 | claimed_username, claimed_time, claimed_nonce 120 | ): 121 | logger.debug("Claimed nonce failed to validate") 122 | return None 123 | 124 | # If we've gotten this far, the token is valid 125 | nonce_backend.log_used_nonce(claimed_username, claimed_time, claimed_nonce) 126 | return Token(claimed_username, claimed_time) 127 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from django.views.decorators.cache import cache_page 3 | 4 | from . import get_setting 5 | from .views import JWKSEndpointView 6 | 7 | _cache_jwks = cache_page(get_setting("JWKS_VIEW_TTL")) 8 | 9 | app_name = "asymmetric_jwt_auth" 10 | urlpatterns = [ 11 | path(".well-known/jwks.json", _cache_jwks(JWKSEndpointView.as_view()), name="jwks"), 12 | ] 13 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/utils.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import struct 3 | 4 | 5 | def long2intarr(long_int: int) -> list[int]: 6 | _bytes: list[int] = [] 7 | while long_int: 8 | long_int, r = divmod(long_int, 256) 9 | _bytes.insert(0, r) 10 | return _bytes 11 | 12 | 13 | def long_to_base64(n: int, mlen: int = 0) -> str: 14 | bys = long2intarr(n) 15 | if mlen: 16 | _len = mlen - len(bys) 17 | if _len: 18 | bys = [0] * _len + bys 19 | data = struct.pack("%sB" % len(bys), *bys) 20 | if not len(data): 21 | data = b"\x00" 22 | s = base64.urlsafe_b64encode(data).rstrip(b"=") 23 | return s.decode("ascii") 24 | -------------------------------------------------------------------------------- /src/asymmetric_jwt_auth/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpRequest, JsonResponse 2 | from django.views import View 3 | 4 | from . import get_setting 5 | from .keys import FacadePublicKey, PublicKey 6 | 7 | 8 | class JWKSEndpointView(View): 9 | def get(self, request: HttpRequest) -> JsonResponse: 10 | keys = self.list_pub_keys() 11 | data = { 12 | "keys": [key.as_jwk for key in keys], 13 | } 14 | return JsonResponse(data) 15 | 16 | def list_pub_keys(self) -> list[FacadePublicKey]: 17 | keys: list[FacadePublicKey] = [] 18 | for _key in get_setting("SIGNING_PUBLIC_KEYS"): 19 | if isinstance(_key, PublicKey): 20 | keys.append(_key) # type:ignore[arg-type] 21 | else: 22 | exc, key = PublicKey.load_serialized_public_key(_key) 23 | if key is not None: 24 | keys.append(key) 25 | return keys 26 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = True 3 | toxworkdir={env:TOX_WORK_DIR:.tox} 4 | envlist = py{311,312,313}-django{420,510,520} 5 | 6 | [testenv] 7 | allowlist_externals = 8 | bash 9 | deps = 10 | django420: django>=4.2,<4.3 11 | django510: django>=5.1,<5.2 12 | django520: django>=5.2,<5.3 13 | setenv = 14 | PYTHONWARNINGS = d 15 | # Install the dependencies managed by Poetry, except for Django (which was 16 | # already installed by tox). This prevents Poetry from overwriting the version 17 | # of Django we're trying to test with the version in the lock file. 18 | # Adapted from here: https://github.com/python-poetry/poetry/discussions/4307 19 | commands_pre = 20 | bash -c 'poetry export --with dev --without-hashes -f requirements.txt | \ 21 | grep -v "^[dD]jango==" | \ 22 | pip install --no-deps -r /dev/stdin' 23 | commands = 24 | flake8 src sandbox 25 | mypy src/asymmetric_jwt_auth/ 26 | coverage erase 27 | coverage run --branch --source asymmetric_jwt_auth {toxinidir}/sandbox/manage.py test --noinput asymmetric_jwt_auth 28 | - coverage report -m 29 | --------------------------------------------------------------------------------