├── .github └── workflows │ ├── build.yml │ └── codeql-analysis.yml ├── .gitignore ├── .mailmap ├── AUTHORS ├── ChangeLog ├── LICENSE ├── README.rst ├── atlassian_jwt_auth ├── __init__.py ├── algorithms.py ├── auth.py ├── contrib │ ├── README.md │ ├── __init__.py │ ├── aiohttp │ │ ├── __init__.py │ │ ├── auth.py │ │ ├── key.py │ │ └── verifier.py │ ├── django │ │ ├── __init__.py │ │ ├── decorators.py │ │ └── middleware.py │ ├── flask_app │ │ ├── __init__.py │ │ └── decorators.py │ ├── requests.py │ ├── server │ │ └── __init__.py │ └── tests │ │ ├── __init__.py │ │ ├── aiohttp │ │ ├── __init__.py │ │ ├── test_auth.py │ │ ├── test_public_key_provider.py │ │ └── test_verifier.py │ │ ├── test_requests.py │ │ └── utils.py ├── exceptions.py ├── frameworks │ ├── __init__.py │ ├── common │ │ ├── __init__.py │ │ ├── asap.py │ │ ├── backend.py │ │ ├── decorators.py │ │ ├── tests │ │ │ ├── __init__.py │ │ │ ├── test_decorators.py │ │ │ └── test_utils.py │ │ └── utils.py │ ├── django │ │ ├── __init__.py │ │ ├── backend.py │ │ ├── decorators.py │ │ ├── middleware.py │ │ └── tests │ │ │ ├── __init__.py │ │ │ ├── settings.py │ │ │ ├── test_django.py │ │ │ ├── urls.py │ │ │ └── views.py │ ├── flask │ │ ├── __init__.py │ │ ├── backend.py │ │ ├── decorators.py │ │ └── tests │ │ │ ├── __init__.py │ │ │ └── test_flask.py │ └── wsgi │ │ ├── __init__.py │ │ ├── backend.py │ │ ├── middleware.py │ │ └── tests │ │ ├── __init__.py │ │ └── test_wsgi.py ├── key.py ├── signer.py ├── tests │ ├── __init__.py │ ├── test_key.py │ ├── test_private_key_provider.py │ ├── test_public_key_provider.py │ ├── test_signer.py │ ├── test_signer_private_key_repo.py │ ├── test_verifier.py │ └── utils.py └── verifier.py ├── requirements.txt ├── setup.cfg ├── setup.py └── test-requirements.txt /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-20.04 9 | strategy: 10 | matrix: 11 | python-version: [3.9, "3.10", "3.11", "3.12", "3.13"] 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip wheel setuptools 22 | pip install -q pycodestyle==2.9.1 flake8==5.0.4 23 | - name: Lint 24 | run: | 25 | pycodestyle . 26 | flake8 . 27 | - name: Test 28 | run: | 29 | pip install wheel 30 | pip install -r requirements.txt 31 | pip install -r test-requirements.txt 32 | pip install -e . 33 | python -Wd -m pytest . 34 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '18 7 * * 3' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: [ 'python' ] 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 33 | # Learn more: 34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v2 39 | 40 | # Initializes the CodeQL tools for scanning. 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@v1 43 | with: 44 | languages: ${{ matrix.language }} 45 | # If you wish to specify custom queries, you can do so here or in a config file. 46 | # By default, queries listed here will override any specified in a config file. 47 | # Prefix the list here with "+" to use these queries and those in the config file. 48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 49 | 50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 51 | # If this step fails, then you should remove it and run the build manually (see below) 52 | - name: Autobuild 53 | uses: github/codeql-action/autobuild@v1 54 | 55 | # ℹ️ Command-line programs to run using the OS shell. 56 | # 📚 https://git.io/JvXDl 57 | 58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 59 | # and modify them (or add more) to build your code if your project 60 | # uses a compiled language 61 | 62 | #- run: | 63 | # make bootstrap 64 | # make release 65 | 66 | - name: Perform CodeQL Analysis 67 | uses: github/codeql-action/analyze@v1 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # PyBuilder 57 | target/ 58 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | David Black 2 | David Black 3 | Mark Adams 4 | Jeremy Shoemaker 5 | James Meldrum 6 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Brian Edwards 2 | Cameron Stewart 3 | David Black 4 | Elliana May 5 | Grant Mathews 6 | James Bunton 7 | James Meldrum 8 | Jeremy Baumont 9 | Jeremy Shoemaker 10 | Marcus Bertrand 11 | Marcus Bertrand 12 | Mark Adams 13 | Moinul Hossain 14 | Oleksandr Fedorov 15 | Sviatoslav Sydorenko 16 | Waldemar Hummer 17 | dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 18 | -------------------------------------------------------------------------------- /ChangeLog: -------------------------------------------------------------------------------- 1 | CHANGES 2 | ======= 3 | 4 | 20.0.0 5 | ------ 6 | 7 | * Add release notes for version 20.0.0 8 | * Sem-ver: bugfix Update various test dependency versions 9 | * Sem-Ver: api-break Drop support for python 3.8 as it is now EOL 10 | * Sem-Ver: api-break Do not specify a version for CacheControl 11 | 12 | 19.1.0 13 | ------ 14 | 15 | * Add release notes for version 19.1.0 and update the AUTHORS file 16 | * Sem-ver: bugfix Move away from using utcnow as it is deprecated and scheduled for removal 17 | * Sem-ver: bugfix remove pytest-runner dependency 18 | * Sem-ver: bugfix install setuptools in ci for python 3.12 & 3.13 19 | * Sem-Ver: bugfix update ci action versions 20 | * Sem-ver: bugfix Update build.yml to test 3.1{2,3} 21 | * Sem-ver: feature add python 3.12 & 3.13 support 22 | * Sem-Ver: bugfix Update aiohttp from version 3.9.1 to 3.9.4 23 | 24 | 19.0.0 25 | ------ 26 | 27 | * Add release notes for version 19.0.0 28 | * Sem-Ver: api-break Change the default token lifetime to be 1 minute - it was previously 1 hour 29 | 30 | 18.0.1 31 | ------ 32 | 33 | * Add release notes for version 18.0.1 34 | * Sem-Ver: bugfix Update package classifier information to include all supported python versions 35 | 36 | 18.0.0 37 | ------ 38 | 39 | * Add release notes for version 18.0.0 40 | * Sem-Ver: bugfix Update CacheControl from version 0.12.14 to 0.13.1 41 | * Sem-Ver: bugfix use aiohttp version 3.9.1 for testing 42 | * Sem-Ver: bugfix use Django versions from 3.2.9 before 5.0.0 for testing 43 | * Sem-Ver: bugfix use flask versions from 2.0.3 before 2.4.0 for testing 44 | * Sem-Ver: api-break Drop support for python 3.6 & 3.7 as they have been end of life for a while 45 | * Sem-Ver: feature add support for python 3.11 46 | * Sem-Ver: bugfix Fix the flask tests for python 3.11 47 | 48 | 17.0.1 49 | ------ 50 | 51 | * Add release notes for version 17.0.1 52 | * Sem-Ver: bugfix Update CacheControl from version 0.12.11 to 0.12.14 53 | * Sem-Ver: bugfix Support ASAP\_VALID\_AUDIENCE being a list so that multiple auds can be accepted 54 | 55 | 17.0.0 56 | ------ 57 | 58 | * Add release notes for version 17.0.0 59 | * Sem-Ver: bugfix Use ubuntu 20.04 in ci so that we can test against python 3.6 60 | * Sem-Ver: api-break Have PyJWT specify the version ranges for the cryptography library 61 | 62 | 16.0.0 63 | ------ 64 | 65 | * Add release notes for version 16.0.0 66 | * Sem-Ver: bugfix Update pycodestyle and flake8 in the ci build to version 2.9.1 & 5.0.4 respectively 67 | * Sem-Ver: bugfix use aiohttp version 3.8.3 for testing 68 | * Sem-Ver: bugfix use flask versions >= 2.0.3 < 2.3.0 for testing 69 | * Sem-Ver: api-break upgrade cryptography from using a version >= 3.3.2 < 38.0.0 to using a version >= 3.3.2 < 39.0.0 70 | 71 | 15.0.1 72 | ------ 73 | 74 | * Add release notes for version 15.0.1 75 | * Sem-Ver: bugfix Fix two possible memory leaks related to the use of lru\_cache on methods 76 | 77 | 15.0.0 78 | ------ 79 | 80 | * Add release notes for version 15.0.0 81 | * Sem-Ver: bugfix Update the CodeQL configuration with regards to the new name of the default branch (main) 82 | * Sem-Ver: bugfix Use aiohttp version 3.8.1 for testing 83 | * Sem-Ver: bugfix use flask versions >= 2.0.3 < 2.2.0 for testing 84 | * Sem-Ver: bugfix Use pytest versions < 8.0.0 for testing 85 | * Sem-Ver: api-break upgrade cachecontrol from version 0.12.10 to 0.12.11 86 | * Sem-Ver: api-break upgrade cryptography from using a version >= 3.3.2 < 37.0.0 to using a version >= 3.3.2 < 38.0.0 87 | 88 | 14.0.1 89 | ------ 90 | 91 | * Add release notes for version 14.0.1 and update the AUTHORS file 92 | * Sem-Ver: bugfix The wsgi tests dir was missing a \_\_init\_\_.py file 93 | * Sem-Ver: bugfix upgrade pyjwt to use a version >= 2.4.0 < 3.0.0 94 | * Sem-Ver: bugfix Update the atlassian-httptest test dep to version 1.0.0 95 | 96 | 14.0.0 97 | ------ 98 | 99 | * Add release notes for version 14.0.0 100 | * Sem-Ver: bugfix Stop testing with PyJWT < 2.0.0 101 | * Sem-Ver: bugfix Move away from using asynctest in python versions where unittest async utils are available (3.8 and above) 102 | * Sem-Ver: feature add support for python 3.10 103 | * Sem-Ver: bugfix upgrade the version of Django used for testing to be >= 3.2.9 and < 3.3.0 104 | * Sem-Ver: bugfix upgrade the version of Flask used for testing to be >= 2.0.3 and < 2.1.0. Note: As part of this we no longer pin specific versions of Jinja2, itsdangerous or MarkupSafe 105 | * Sem-Ver: bugfix Switch from using nose to pytest for running tests 106 | * Sem-Ver: bugfix Remove travis ci 107 | * Sem-Ver: api-break upgrade cryptography from using a version >= 3.3.2 < 36.0.0 to using a version >= 3.3.2 < 37.0.0 108 | * Sem-Ver: api-break upgrade cachecontrol from version 0.12.6 to 0.12.10 109 | * Sem-Ver: bugfix Enable codeql analysis 110 | 111 | 13.0.0 112 | ------ 113 | 114 | * Add release notes for version 13.0.0 115 | * Sem-Ver: api-break upgrade pyjwt from using a version >= 1.5.2 < 2.2.0 to using a version >= 2.2.0 < 2.3.0 116 | * Sem-Ver: bugfix change description-file to be description\_file 117 | 118 | 12.0.0 119 | ------ 120 | 121 | * Add release notes for version 12.0.0 122 | * Sem-Ver: feature Add support for python 3.9 123 | * Sem-Ver: api-break upgrade cryptography from using a version >= 3.3.2 < 3.5.0 to use a version >= 3.3.2 and < 36.0.0 124 | 125 | 11.0.1 126 | ------ 127 | 128 | * Add release notes for version 11.0.1 129 | * Sem-Ver: bugfix Add some missing package metadata information 130 | 131 | 11.0.0 132 | ------ 133 | 134 | * Sem-Ver: bugfix The aiohttp public key retriever incorrectly provided a proxies argument when it needed to provide a proxy argument 135 | * Sem-Ver: feature Implement public key object caching 136 | * Sem-Ver: api-break upgrade cryptography from using a version >= 3.3.1 < 3.4.0 to use a version >= 3.3.2 and < 3.5.0 137 | * Sem-Ver: bugfix upgrade PyJWT from using a version >= 1.5.2 < 2.1.0 to use a version >= 1.5.2 and < 2.2.0 138 | * Sem-Ver: bugfix Update the version of aiohttp used in testing from 3.6.2 to 3.7.4 139 | 140 | 10.1.0 141 | ------ 142 | 143 | * Add release notes for version 10.1.0 144 | * Sem-Ver: feature Add support for using PyJWT 2.0.x 145 | 146 | 10.0.0 147 | ------ 148 | 149 | * Add release notes for version 10.0.0 150 | * Sem-Ver: api-break Drop support for python 3.5 as part of upgrading cryptography from using a version >= 3.2.1 < 3.3.0 to use a version >= 3.3.1 and < 3.4.0 151 | 152 | 9.0.0 153 | ----- 154 | 155 | * Add release notes for version 9.0.0 156 | * Sem-Ver: feature Add support for python 3.8 157 | * Sem-Ver: bugfix Add github actions for CI 158 | * Sem-Ver: api-break Drop support for python 2.7 159 | 160 | 8.0.2 161 | ----- 162 | 163 | * Add release notes for version 8.0.2 164 | * Sem-Ver: bugfix upgrade cryptography from using a version >= 3.1.0 < 3.2.0 to use a version >= 3.2.1 and < 3.3.0 165 | 166 | 8.0.1 167 | ----- 168 | 169 | * Add release notes for version 8.0.1 170 | * Sem-Ver: bugfix upgrade cryptography from using a version >= 2.8.0 < 2.9.0 to use a version >= 3.1.0 and < 3.2.0 171 | 172 | 8.0.0 173 | ----- 174 | 175 | * Add release notes for version 8.0.0 176 | * Sem-Ver: api-break Optimise the standard requests session based public key retrieval by obtaining proxy information from the environment once per a public key server and setting session.trust\_env to False. As a result of this change retrieving the same public key, with caching enabled, 10,000 times takes ~ 6 seconds instead of ~ 14 seconds 177 | * Sem-Ver: bugfix For python 2.7 and 3.5 testing we need to pin itsdangerous to a version < 2.0.0 178 | 179 | 7.1.0 180 | ----- 181 | 182 | * Add release notes for version 7.1.0 183 | * Sem-Ver: bugfix When a connection error is encountered while attempting to fetch public keys in HTTPSMultiRepositoryPublicKeyRetriever continue to attempt key retrieval using other retrievers 184 | * Sem-Ver: feature Add a handle\_retrieval\_exception method to the HTTPSMultiRepositoryPublicKeyRetriever class 185 | * Sem-Ver: bugfix In CI move to using setup.py nosetests as setup.py test has been deprecated 186 | * Sem-Ver: bugfix Run flake8 in ci 187 | * Sem-Ver: bugfix Flake8 fix up - add an explicit check that the aud claim has been provided. This is not a breaking change because even if verify\_jwt was to use an audience value of None & a jwt did not have an aud claim, a KeyError would be raised 188 | * Sem-Ver: bugfix Fix some issues that flake8 detected 189 | * Sem-Ver: bugfix Fix up the name of the None algorithm auth signer 190 | * Sem-Ver: bugfix For python 2.7 and 3.5 testing we need to pin MarkupSafe to a version < 2.0.0 191 | * Sem-Ver: bugfix Add an explicit test for how none algorithm jwt are handled 192 | 193 | 7.0.0 194 | ----- 195 | 196 | * Add release notes for version 7.0.0 197 | * Sem-Ver: feature Log information on general exceptions in addition to specific exceptions 198 | * Sem-Ver: bugfix Fix some issues that flake8 detected 199 | * Sem-Ver: api-break Disable jti uniqueness checking by default 200 | * Sem-Ver: bugfix Reduce the backend verifier cache max size from 130 to 20 201 | * Sem-Ver: feature Add support to the various frameworks for being able to specify to not check jti uniqueness 202 | * Sem-Ver: feature Cache Backend verifiers 203 | * Sem-Ver: feature Add logging to the framework asap token checking code 204 | * Sem-Ver: bugfix Catch SubjectDoesNotMatchIssuerException in the frameworks 205 | * Sem-Ver: feature Add a SubjectDoesNotMatchIssuerException for when the subject does not match the issuer 206 | * Sem-Ver: bugfix Catch JtiUniquenessException and respond with a 401 inside \_process\_asap\_token 207 | * Sem-Ver: bugfix Deduplicate the various framework test create\_token methods 208 | * Sem-Ver: bugfix Fix the spelling of the duplicate jti exception (rename JtiUniqunessException to JtiUniquenessException) 209 | * Sem-Ver: feature Allow SettingsDict instances to be hashed 210 | * Sem-Ver: feature Add and use a specific exception, JtiUniqunessException, for when a JTI is used more than once 211 | * Sem-Ver: bugfix Switch the wsgi tests to use unittest assertions 212 | 213 | 6.0.0 214 | ----- 215 | 216 | * Add release notes for version 6.0.0 217 | * Sem-Ver: bugfix For python 2.7 and 3.5 testing we need to pin Jinja2 to a version < 3.0.0 218 | * Sem-Ver: bugfix Update CacheControl from version 0.12.5 to 0.12.6 219 | * Sem-Ver: feature Add support for python 3.7 220 | * Sem-Ver: api-break Drop support for python3.4 221 | 222 | 5.0.3 223 | ----- 224 | 225 | * Sem-Ver: bugfix upgrade cryptography from using a version >= 2.7.0 < 2.8.0 to use a version >= 2.8.0 and < 2.9.0 226 | * Add an example on how to generate jwt using a data uri private key 227 | 228 | 5.0.2 229 | ----- 230 | 231 | * Sem-Ver: bugfix upgrade cryptography from using a version >= 2.5.0 < 2.6.0 to use a version >= 2.7.0 and < 2.8.0 232 | 233 | 5.0.1 234 | ----- 235 | 236 | * Sem-Ver: bugfix Fix the backend reference in OldStyleASAPMiddleware 237 | 238 | 5.0.0 239 | ----- 240 | 241 | * Sem-Ver: api-break Re-use verifiers in the various middlewares and add an optional verifier argument to the _process_asap_token method 242 | * Sem-Ver: api-break Share request sessions across key retriever instances so as to use a common cache 243 | 244 | 4.1.2 245 | ----- 246 | 247 | * Sem-Ver: bugfix upgrade cryptography from using a version >= 2.4.0 < 2.5.0 to use a version >= 2.5.0 and < 2.6.0 248 | * Sem-Ver: bugfix upgrade the version of Django used for testing to using version 1.11 249 | * Sem-Ver: bugfix upgrade pbr to use a version before 6.0.0 250 | * Sem-Ver: bugfix upgrade cryptography from using a version >= 2.3.0 < 2.4.0 to use a version >= 2.4.0 and < 2.5.0 251 | * Sem-Ver: bugfix upgrade the version of flask used for testing from versions below 0.12 to versions below 1.1.0 252 | * Sem-Ver: bugfix upgrade CacheControl from version 0.12.4 to 0.12.5 253 | 254 | 4.1.1 255 | ----- 256 | 257 | * Sem-Ver: bugfix Django middleware super call 258 | 259 | 4.1.0 260 | ----- 261 | 262 | * Sem-Ver: feature Reduce the time taken to generate a jwt by caching loaded private key instances 263 | 264 | 4.0.2 265 | ----- 266 | 267 | * Sem-Ver: bugfix upgrade cryptography from using a version >= 2.2.1 < 2.3.0 to use a version >= 2.3.0 and < 2.4.0 268 | 269 | 4.0.1 270 | ----- 271 | 272 | * Sem-Ver: bugfix When asap is not required and no asap token has been provided return early in _process_asap_token 273 | * Update the changelog with more specific information on has changed with regards to the Django and Flask support 274 | 275 | 4.0.0 276 | ----- 277 | 278 | * Sem-Ver: feature Add WSGI middleware 279 | * Sem-Ver: api-break Rework Django and Flask support. As part of this change the Django and Flask support has moved from the `contrib` package to the new `frameworks` package. 280 | * Add a readme to the contrib module 281 | 282 | 3.6.0 283 | ----- 284 | 285 | * Sem-Ver: feature Support disabling checking if jwt jti are unique 286 | * Sem-Ver: bugfix The HTTPSMultiRepositoryPublicKeyRetriever should try the next key repository upon encountering a server error (status code >= 500) 287 | 288 | 3.5.0 289 | ----- 290 | 291 | * Sem-Ver: feature Support reusing tokens 292 | 293 | 3.4.0 294 | ----- 295 | 296 | * Sem-Ver: feature Support specifying if the subject should match the issue in the Django ASAPForwardedMiddleware 297 | * Sem-Ver: feature Support specifying if the subject should match the issue in the Django requires_asap decorator 298 | * Sem-Ver: feature Add support for specifying the subject for JWTAuthSigner to use when generating claims 299 | 300 | 3.3.1 301 | ----- 302 | 303 | * Sem-Ver: bugfix Use the raw string notation when specifying the key identifier regex 304 | * Sem-Ver: bugfix upgrade cryptography from using a version >= 2.1.3 < 2.2.0 to use a version >= 2.2.1 and < 2.3.0 305 | * Sem-Ver: bugfix upgrade CacheControl from version 0.12.3 to 0.12.4 306 | 307 | 3.3.0 308 | ----- 309 | 310 | * Sem-Ver: feature Add better Django ASAP middleware 311 | 312 | 3.2.2 313 | ----- 314 | 315 | * Sem-Ver: bugfix Fix tuple assignment in wrapped exception mechanism. 316 | 317 | 3.2.1 318 | ----- 319 | 320 | * Sem-Ver: bugfix upgrade cryptography from using a version >= 2.0.3 < 2.1.0 to use a version >= 2.1.3 and < 2.2.0 321 | * Sem-Ver: bugfix upgrade CacheControl from version 0.12.1 to 0.12.3 322 | 323 | 3.2.0 324 | ----- 325 | 326 | * Sem-Ver: feature Cleanup responses from requires_asap 327 | * Sem-Ver: bugfix Check authorization scheme in requires_asap and also send a WWW-Authenticate header where appropriate 328 | * Sem-Ver: bugfix Clean up the django and flask requires_asap decorators by sharing their code 329 | * Sem-Ver: bugfix HTTPSMultiRepositoryPublicKeyRetriever should raise PublicKeyRetrieverException and not KeyError when a key is not found 330 | * Sem-Ver: bugfix Improvements to the readme file 331 | * Sem-Ver: bugfix Make _seen_jti a ringbuffer and increase its capacity to 1000 332 | 333 | 3.1.0 334 | ----- 335 | 336 | * Sem-Ver: feature Add Django middleware to auth forwarded clients 337 | * Sem-Ver: bugfix upgrade cryptography from using a version >= 1.8.1 < 1.9.0 to use a version >= 2.0.3 and < 2.1.0 338 | 339 | 3.0.1 340 | ----- 341 | 342 | * Sem-Ver: bugfix upgrade PyJWT from version 1.4.2 to use a version >= 1.5.2 but less than 2.0.0 343 | 344 | 3.0.0 345 | ----- 346 | 347 | * Sem-Ver: feature Add a new HTTPSMultiRepositoryPublicKeyRetriever class which allows using multiple public key repositories. 348 | * Sem-Ver: feature Add and use library specific exceptions instead of using ValueError 349 | * Sem-Ver: api-break Add support for customising the value of the leeway used in the django and flask contrib code through the ASAP_VALID_LEEWAY setting & switch to a default leeway of 0 seconds 350 | 351 | 2.11.2 352 | ------ 353 | 354 | * Sem-Ver: bugfix Fix the requires_asap decorator for python 3 by forcing the HTTP_AUTHORIZATION header into bytes before parsing it 355 | 356 | 2.11.1 357 | ------ 358 | 359 | * Sem-Ver: bugfix Fix the default value for the auth header used in the Django requires_asap decorator to work in Python 3 360 | * Sem-Ver: bugfix Warn when an import error occurs when importing aiohttp so that the tests do not fail in python >= 3.5 when aiohttp is not installed 361 | 362 | 2.11.0 363 | ------ 364 | 365 | * Sem-Ver: feature Provide aiohttp support 366 | 367 | 2.10.2 368 | ------ 369 | 370 | * Sem-Ver: bugfix Fix the decorator for Django (#38) 371 | 372 | 2.10.1 373 | ------ 374 | 375 | * Sem-Ver: bugfix upgrade cryptography from using a version >= 1.5.0 < 1.6.0 to use a version >= 1.8.1 and < 1.9.0 376 | * Sem-Ver: bugfix upgrade CacheControl from version 0.11.6 to 0.12.1 377 | 378 | 2.10.0 379 | ------ 380 | 381 | * Sem-Ver: feature support passing in additional claims to contrib.requests.JWTAuth 382 | 383 | 2.9.0 384 | ----- 385 | 386 | * Sem-Ver: feature add Django support - BBCDEV-4046. 387 | 388 | 2.8.1 389 | ----- 390 | 391 | * Sem-Ver: bugfix upgrade cryptography from using a version >= 1.3.0 < 1.4.0 to use a version >= 1.5.0 and < 1.6.0 392 | 393 | 2.8.0 394 | ----- 395 | 396 | * Sem-Ver: feature Added ASAP_KEY_RETRIEVER_CLASS to simplify Flask testing 397 | * Sem-Ver: bugfix HTTPSPublicKeyRetriever should raise a ValueError if the base_url is None. 398 | * Sem-Ver: bugfix Fix an issue where Flask config values were not referenced properly 399 | 400 | 2.7.0 401 | ----- 402 | 403 | * Sem-Ver: feature add to contrib flask_app that provides a @requires_asap decorator 404 | * Sem-Ver: bugfix upgrade PyJWT from version 1.4.0 to 1.4.2 405 | 406 | 2.6.0 407 | ----- 408 | 409 | * Sem-Ver: feature support passing through kwargs for the signer created in create_jwt_auth 410 | * Generate a universal wheel 411 | 412 | 2.5.2 413 | ----- 414 | 415 | * Sem-Ver: bugfix make the DataUriPrivateKeyRetriever able to be used with a signer to generate jwt 416 | * Sem-Ver: bugfix support content-type headers that contain parameters in addition to the media-type. 417 | 418 | 2.5.1 419 | ----- 420 | 421 | * Sem-Ver: bugfix upgrade cryptography from using a version >= 1.2.2 < 1.3.0 to use a version >= 1.3.0 and < 1.4.0 422 | 423 | 2.5.0 424 | ----- 425 | 426 | * Add support for obtaining a key identifier and private key from a data uri. 427 | * Standardise the PrivateKeyRepository classes and add docstring to the FilePrivateKeyRepository class 428 | * Sem-Ver: bugfix upgrade CacheControl from version 0.11.5 to 0.11.6 429 | * Sem-Ver: bugfix upgrade cryptography from version 1.2.2 to use a version >= 1.2.2 and < 1.3.0 430 | 431 | 2.4.0 432 | ----- 433 | 434 | * Support providing additional_claims when generating a jwt. 435 | * Update the location of the asap specification 436 | * Rearranged the README and added badge for pypi 437 | 438 | 2.3.0 439 | ----- 440 | 441 | * Added atlassian_jwt_auth.contrib.requests.JWTAuth 442 | * Move test requirements out of setup.py and into test-requirements.txt 443 | * Update pbr from version 1.0.1 to 1.8.1 444 | * Support python 3.5. 445 | 446 | 2.2.0 447 | ----- 448 | 449 | * Sem-Ver: bugfix upgrade cryptography from version 1.1.1 to 1.2.1 450 | * Add the ability to accept JWT where the subject does not match the issuer 451 | 452 | 2.1.1 453 | ----- 454 | 455 | * Sem-Ver: bugfix upgrade cryptography from version 1.1 to 1.1.1 456 | * Sem-Ver: bugfix use a version of requests >= 2.8.1 but less than 3.0.0. 457 | 458 | 2.1.0 459 | ----- 460 | 461 | * Sem-Ver: feature - Pass leeway param through to jwt.decode 462 | 463 | 2.0.0 464 | ----- 465 | 466 | * Make use of new require_iat and require_exp options that PyJWT now accepts 467 | * Sem-Ver: bugfix update the PyJWT dep from 1.3.0 to 1.4.0 468 | * Sem-Ver: bugfix update the cryptography dep from 0.9.1 to 1.0.2 469 | * Update the AUTHORS and the ChangeLog files 470 | * Make the private key repository scanning actually work 471 | * Clean up imports to follow google python style guides 472 | * Support scanning for key file each time generate_jwt is called 473 | * Sem-Ver: bugfix - update the build location information to reflect the build status of the master branch 474 | * Sem-Ver: bugfix - update the build location information 475 | * Sem-Ver: bugfix - update the installation instructions 476 | * release 1.0.8 477 | 478 | 1.0.8 479 | ----- 480 | 481 | * add the generated pbr changelog file changes in 482 | * Add authors file 483 | 484 | 1.0.7 485 | ----- 486 | 487 | * Add CI build information to the readme file 488 | * Merged in update_cryptography_from_0.9_to_0.9.1 (pull request #4) 489 | * Merged in use_supported_jwt_api_to_get_header (pull request #3) 490 | * Use the new pyjwt api to get an verified header instead of calling their internal API 491 | * update cryptography from 0.9 to 0.9.1 492 | * Use pbr for setup configuration 493 | * Add a mostly-generated Changelog file 494 | 495 | 1.0.6 496 | ----- 497 | 498 | * Release version 1.0.6 499 | * Merged in update_dependencies_28_05_2015 (pull request #2) 500 | * Update PyJWT from version 1.1.0 to 1.3.0 501 | * Upgrade CacheControl from version 0.11.2 to 0.11.5 502 | * Upgrade cryptography from 0.8.2 to 0.9 503 | 504 | 1.0.5 505 | ----- 506 | 507 | * release 1.0.5 508 | * Merged in add_caching_for_key_retriever (pull request #1) 509 | * update requests from 2.6.0 to 2.7.0 510 | * Add caching to public key retrieval requests via cachecontrol 511 | 512 | 1.0.4 513 | ----- 514 | 515 | * specify the version in setup.py from __init__.py - which now contains a __version__ field 516 | 517 | 1.0.3 518 | ----- 519 | 520 | * bump the version to 1.0.3 521 | * rename the private _key field of the JWTAuthSigner class to _private_key_pem 522 | * s/signed_claims/a_jwt/ in the test code 523 | * http headers are case insensitive - so the content-type check should be done in a case insensitive fashion 524 | * pass through requests_kwargs through to public_key_retriever.retrieve(...) 525 | * extract the key_id obtaining code from the jwt header out into a function 526 | * s/verify_claims/verify_jwt/ 527 | * s/get_signed_claims/generate_jwt/ 528 | * s/_get_claims/_generate_claims/ 529 | * rename the JWTAuthSigner 'key' parameter to 'private_key_pem' 530 | * update the readme with example use of the package 531 | * set the pep8 version to 1.6.2 in the travis-ci file 532 | * Add a travis-ci yaml file 533 | 534 | 0.0.2 535 | ----- 536 | 537 | * release 0.0.2 538 | * s/assertNotEquals/assertNotEqual/ 539 | * add support for python 2.7.X 540 | * README.md edited online with Bitbucket 541 | 542 | 0.0.1 543 | ----- 544 | 545 | * Make HTTPSPublicKeyRetriever take in and pass through keyword arguments for the requests.get(. 546 | * remove the unused get_new_rsa_private_key_in_pem_format import from test_verifier 547 | * pep8 fix ups 548 | * update the test_signer code to use the new mixins 549 | * Update the test_verifier code 550 | * s/get_new_private_key/get_new_private_key_in_pem_format/ in the mixin classes 551 | * Add JWTAuthVerifierRSATest and JWTAuthVerifierECDSATest classes which used the new mixins. Also rename TestJWTAuthVerifier to BaseJWTAuthVerifierTest 552 | * Add some jwt algorithm mixins 553 | * Make the KeyIdentifier.key_id field a property 554 | * pep8 fix up 555 | * Add a test to check that an jwt with a jti that has already been used is rejected 556 | * update the jti rejection message 557 | * wording change 558 | * minor change to test_verify_claims_with_jwt_lasting_gt_max_time 559 | * Add a test to check that jwt with lifetimes longer than the allowed maximum by the specification are rejected 560 | * add a test to cover when claims['iss'] != claims['sub'] 561 | * if a key identifier does not contain a / then check if the key_id is equal to the claims issuer in verify_claims 562 | * add a test to cover that if key_identifier does not start with issuer then an error is raised in verify_claims 563 | * remove the superfluous 'the' in the issuer does not own the supplied public key message 564 | * re-factor the TestJWTAuthVerifier class 565 | * use the utils.get_example_jwt_auth_signer method in test_signer 566 | * Add get_example_jwt_auth_signer to tests/utils 567 | * Add a test for the JWTAuthVerifier 568 | * Add a get_public_key_pem_for_private_key_pem to tests/utils 569 | * create the JWTAuthSigner instance in get_example_jwt_auth_signer with key as a non-keyword style argument 570 | * s/jws/a_jwt/ in verify_claims 571 | * restructure the tests 572 | * Use nose for running tests 573 | * Add a test for JWTAuthSigner.get_signed_claims 574 | * Set test_suite in setup.py 575 | * Add a test to check that the jti changes between _get_claims calls 576 | * use the timestamp of now in the jti instead of the string representation of the datetime object 577 | * Add some tests 578 | * Extract and fix getting the time in signer.py 579 | * Fix up some minor errors in signer.py 580 | * remove the unused os import from setup.py 581 | * '..' is not permitted in a key identifier 582 | * validate_key_identifier should never of taken in 'self' it only needs a key identifier 583 | * add a setup.py file 584 | * Add completely untested code 585 | * init 586 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Atlassian Corporation Pty Ltd. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============================ 2 | Atlassian JWT authentication 3 | ============================ 4 | 5 | .. image:: https://github.com/atlassian/asap-authentication-python/workflows/Tests/badge.svg 6 | .. image:: https://img.shields.io/pypi/v/atlassian-jwt-auth.svg 7 | :target: https://pypi.org/project/atlassian-jwt-auth 8 | 9 | This package provides an implementation of the `Service to Service Authentication `_ specification. 10 | 11 | ---- 12 | 13 | Installation 14 | ============ 15 | 16 | To install simply run 17 | 18 | .. code:: sh 19 | 20 | $ pip install atlassian-jwt-auth 21 | 22 | Using this library 23 | ================== 24 | 25 | To create a JWT for authentication 26 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 27 | 28 | .. code:: python 29 | 30 | import atlassian_jwt_auth 31 | 32 | 33 | signer = atlassian_jwt_auth.create_signer('issuer', 'issuer/key', private_key_pem) 34 | a_jwt = signer.generate_jwt('audience') 35 | 36 | 37 | To create a JWT using a file on disk in the conventional location 38 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 39 | 40 | Each time you call ``generate_jwt`` this will find the latest active key file (ends with ``.pem``) and use it to generate your JWT. 41 | 42 | .. code:: python 43 | 44 | import atlassian_jwt_auth 45 | 46 | 47 | signer = atlassian_jwt_auth.create_signer_from_file_private_key_repository('issuer', '/opt/jwtprivatekeys') 48 | a_jwt = signer.generate_jwt('audience') 49 | 50 | To create a JWT using a data uri 51 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 52 | 53 | .. code:: python 54 | 55 | import atlassian_jwt_auth 56 | from atlassian_jwt_auth.key import DataUriPrivateKeyRetriever 57 | 58 | key_id, private_key_pem = DataUriPrivateKeyRetriever('Your base64 encoded data uri').load('issuer') 59 | signer = atlassian_jwt_auth.create_signer('issuer', 'issuer/key', private_key_pem) 60 | a_jwt = signer.generate_jwt('audience') 61 | 62 | 63 | 64 | To make an authenticated HTTP request 65 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 66 | 67 | If you use the ``atlassian_jwt_auth.contrib.requests.JWTAuth`` provider, you 68 | can automatically generate JWT tokens when using the ``requests`` library to 69 | perform authenticated HTTP requests. 70 | 71 | .. code:: python 72 | 73 | import atlassian_jwt_auth 74 | from atlassian_jwt_auth.contrib.requests import JWTAuth 75 | 76 | signer = atlassian_jwt_auth.create_signer('issuer', 'issuer/key', private_key_pem) 77 | response = requests.get( 78 | 'https://your-url', 79 | auth=JWTAuth(signer, 'audience') 80 | ) 81 | 82 | One can also use ``atlassian_jwt_auth.contrib.aiohttp.JWTAuth`` 83 | to authenticate ``aiohttp`` requests: 84 | 85 | .. code:: python 86 | 87 | import aiohttp 88 | 89 | import atlassian_jwt_auth 90 | from atlassian_jwt_auth.contrib.aiohttp import JWTAuth 91 | 92 | signer = atlassian_jwt_auth.create_signer('issuer', 'issuer/key', private_key_pem) 93 | 94 | async with aiohttp.ClientSession() as session: 95 | async with session.get('https://your-url', 96 | auth=JWTAuth(signer, 'audience')) as resp: 97 | ... 98 | 99 | 100 | If you want to reuse tokens that have the same claim within their period of validity 101 | then pass through `reuse_jwts=True` when calling `create_signer`. 102 | For example: 103 | 104 | 105 | .. code:: python 106 | 107 | import atlassian_jwt_auth 108 | import requests 109 | from atlassian_jwt_auth.contrib.requests import JWTAuth 110 | 111 | signer = atlassian_jwt_auth.create_signer('issuer', 'issuer/key', private_key_pem, reuse_jwts=True) 112 | response = requests.get( 113 | 'https://your-url', 114 | auth=JWTAuth(signer, 'audience') 115 | ) 116 | 117 | If you want to generate tokens with a longer lifetime than the default 1 minute period, 118 | you can do so via specifying a `lifetime` value to `create_signer`. 119 | For example: 120 | 121 | 122 | .. code:: python 123 | 124 | import datetime 125 | 126 | import atlassian_jwt_auth 127 | import requests 128 | from atlassian_jwt_auth.contrib.requests import JWTAuth 129 | 130 | signer = atlassian_jwt_auth.create_signer( 131 | 'issuer', 'issuer/key', private_key_pem, 132 | reuse_jwts=True, lifetime=datetime.timedelta(minutes=2)) 133 | response = requests.get( 134 | 'https://your-url', 135 | auth=JWTAuth(signer, 'audience') 136 | ) 137 | 138 | 139 | To verify a JWT 140 | ~~~~~~~~~~~~~~~ 141 | 142 | .. code:: python 143 | 144 | import atlassian_jwt_auth 145 | 146 | public_key_retriever = atlassian_jwt_auth.HTTPSPublicKeyRetriever('https://example.com') 147 | verifier = atlassian_jwt_auth.JWTAuthVerifier(public_key_retriever) 148 | verified_claims = verifier.verify_jwt(a_jwt, 'audience') 149 | 150 | For Python versions starting from ``Python 3.5``, note this library no longer supports python 3.5, ``atlassian_jwt_auth.contrib.aiohttp`` 151 | provides drop-in replacements for the components that 152 | perform HTTP requests, so that they use ``aiohttp`` instead of ``requests``: 153 | 154 | .. code:: python 155 | 156 | import atlassian_jwt_auth.contrib.aiohttp 157 | 158 | public_key_retriever = atlassian_jwt_auth.contrib.aiohttp.HTTPSPublicKeyRetriever('https://example.com') 159 | verifier = atlassian_jwt_auth.contrib.aiohttp.JWTAuthVerifier(public_key_retriever) 160 | verified_claims = await verifier.verify_jwt(a_jwt, 'audience') 161 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/__init__.py: -------------------------------------------------------------------------------- 1 | from atlassian_jwt_auth.algorithms import get_permitted_algorithm_names # noqa 2 | 3 | from atlassian_jwt_auth.signer import ( # noqa 4 | create_signer, 5 | create_signer_from_file_private_key_repository, 6 | ) 7 | 8 | from atlassian_jwt_auth.key import ( # noqa 9 | KeyIdentifier, 10 | HTTPSPublicKeyRetriever, 11 | ) 12 | 13 | from atlassian_jwt_auth.verifier import ( # noqa 14 | JWTAuthVerifier, 15 | ) 16 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/algorithms.py: -------------------------------------------------------------------------------- 1 | def get_permitted_algorithm_names(): 2 | """ returns permitted algorithm names. """ 3 | return [ 4 | 'RS256', 5 | 'RS384', 6 | 'RS512', 7 | 'ES256', 8 | 'ES384', 9 | 'ES512', 10 | 'PS256', 11 | 'PS384', 12 | 'PS512' 13 | ] 14 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/auth.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import atlassian_jwt_auth 4 | 5 | 6 | class BaseJWTAuth(object): 7 | """Adds a JWT bearer token to the request per the ASAP specification""" 8 | 9 | def __init__(self, signer, audience, *args, **kwargs): 10 | self._audience = audience 11 | self._signer = signer 12 | self._additional_claims = kwargs.get('additional_claims', {}) 13 | 14 | @classmethod 15 | def create(cls, issuer, key_identifier, private_key_pem, audience, 16 | **kwargs): 17 | """Instantiate a JWTAuth while creating the signer inline""" 18 | signer = atlassian_jwt_auth.create_signer(issuer, key_identifier, 19 | private_key_pem, **kwargs) 20 | return cls(signer, audience) 21 | 22 | def _get_header_value(self): 23 | return b'Bearer ' + self._signer.generate_jwt( 24 | self._audience, additional_claims=self._additional_claims) 25 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/contrib/README.md: -------------------------------------------------------------------------------- 1 | The `contrib` directory contains miscellaneous contributions from the community that may be useful when building applications with ASAP auth but are not part of the core library. 2 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/asap-authentication-python/20eb8d99162033328bf66ba67d4097fb820e3744/atlassian_jwt_auth/contrib/__init__.py -------------------------------------------------------------------------------- /atlassian_jwt_auth/contrib/aiohttp/__init__.py: -------------------------------------------------------------------------------- 1 | """Provide asyncio support""" 2 | import sys 3 | 4 | if sys.version_info >= (3, 5): 5 | try: 6 | import aiohttp # noqa 7 | from .auth import JWTAuth # noqa 8 | from .key import HTTPSPublicKeyRetriever # noqa 9 | from .verifier import JWTAuthVerifier # noqa 10 | except ImportError as e: 11 | import warnings 12 | warnings.warn(str(e)) 13 | 14 | 15 | del sys 16 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/contrib/aiohttp/auth.py: -------------------------------------------------------------------------------- 1 | from aiohttp import BasicAuth 2 | 3 | from atlassian_jwt_auth.auth import BaseJWTAuth 4 | 5 | 6 | class JWTAuth(BaseJWTAuth, BasicAuth): 7 | """Adds a JWT bearer token to the request per the ASAP specification 8 | 9 | It should be aiohttp.BasicAuth subclass, so redefine its `__new__` method. 10 | """ 11 | def __new__(cls, *args, **kwargs): 12 | return super().__new__(cls, '') 13 | 14 | def encode(self): 15 | return self._get_header_value().decode(self.encoding) 16 | 17 | 18 | def create_jwt_auth( 19 | issuer, key_identifier, private_key_pem, audience, **kwargs): 20 | """Instantiate a JWTAuth while creating the signer inline""" 21 | return JWTAuth.create( 22 | issuer, key_identifier, private_key_pem, audience, **kwargs) 23 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/contrib/aiohttp/key.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import urllib.parse 3 | 4 | import aiohttp 5 | 6 | from atlassian_jwt_auth.exceptions import PublicKeyRetrieverException 7 | from atlassian_jwt_auth.key import ( 8 | PEM_FILE_TYPE, 9 | HTTPSPublicKeyRetriever as _HTTPSPublicKeyRetriever 10 | ) 11 | 12 | 13 | class HTTPSPublicKeyRetriever(_HTTPSPublicKeyRetriever): 14 | """A class for retrieving JWT public keys with aiohttp""" 15 | _class_session = None 16 | 17 | def __init__(self, base_url, *, loop=None): 18 | if loop is None: 19 | loop = asyncio.get_event_loop() 20 | self.loop = loop 21 | super().__init__(base_url) 22 | 23 | def _get_session(self): 24 | if HTTPSPublicKeyRetriever._class_session is None: 25 | HTTPSPublicKeyRetriever._class_session = aiohttp.ClientSession( 26 | loop=self.loop) 27 | return HTTPSPublicKeyRetriever._class_session 28 | 29 | def _convert_proxies_to_proxy_arg(self, url, requests_kwargs): 30 | """ returns a modified requests_kwargs dict that contains proxy 31 | information in a form that aiohttp accepts 32 | (it wants proxy information instead of a dict of proxies). 33 | """ 34 | proxy = None 35 | if 'proxies' in requests_kwargs: 36 | scheme = urllib.parse.urlparse(url).scheme 37 | proxy = requests_kwargs['proxies'].get(scheme, None) 38 | del requests_kwargs['proxies'] 39 | requests_kwargs['proxy'] = proxy 40 | return requests_kwargs 41 | 42 | async def _retrieve(self, url, requests_kwargs): 43 | requests_kwargs = self._convert_proxies_to_proxy_arg( 44 | url, requests_kwargs) 45 | try: 46 | resp = await self._session.get(url, headers={'accept': 47 | PEM_FILE_TYPE}, 48 | **requests_kwargs) 49 | resp.raise_for_status() 50 | self._check_content_type(url, resp.headers['content-type']) 51 | return await resp.text() 52 | except aiohttp.ClientError as e: 53 | status_code = getattr(e, 'code', None) 54 | raise PublicKeyRetrieverException(e, status_code=status_code) 55 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/contrib/aiohttp/verifier.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import jwt 4 | 5 | from atlassian_jwt_auth import key 6 | from atlassian_jwt_auth.verifier import JWTAuthVerifier as _JWTAuthVerifier 7 | 8 | 9 | class JWTAuthVerifier(_JWTAuthVerifier): 10 | async def verify_jwt(self, a_jwt, audience, leeway=0, **requests_kwargs): 11 | """Verify if the token is correct 12 | 13 | Returns: 14 | dict: the claims of the given jwt if verification is successful. 15 | 16 | Raises: 17 | ValueError: if verification failed. 18 | """ 19 | key_identifier = key._get_key_id_from_jwt_header(a_jwt) 20 | 21 | public_key = self._retrieve_pub_key(key_identifier, requests_kwargs) 22 | if asyncio.iscoroutine(public_key): 23 | public_key = await public_key 24 | 25 | alg = jwt.get_unverified_header(a_jwt).get('alg', None) 26 | public_key_obj = self._load_public_key(public_key, alg) 27 | return self._decode_jwt( 28 | a_jwt, key_identifier, public_key_obj, 29 | audience=audience, leeway=leeway) 30 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/contrib/django/__init__.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | 4 | warnings.warn( 5 | "The atlassian_jwt_auth.contrib.django package is deprecated in 4.0.0 " 6 | "in favour of atlassian_jwt_auth.frameworks.django.", 7 | DeprecationWarning, stacklevel=2 8 | ) 9 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/contrib/django/decorators.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | from django.http.response import HttpResponse 4 | 5 | from atlassian_jwt_auth.frameworks.django.decorators import with_asap 6 | 7 | 8 | def validate_asap(issuers=None, subjects=None, required=True): 9 | """Decorator to allow endpoint-specific ASAP authorization, assuming ASAP 10 | authentication has already occurred. 11 | 12 | :param list issuers: A list of issuers that are allowed to use the 13 | endpoint. 14 | :param list subjects: A list of subjects that are allowed to use the 15 | endpoint. 16 | :param boolean required: Whether or not to require ASAP on this endpoint. 17 | Note that requirements will be still be verified if claims are present. 18 | """ 19 | def validate_asap_decorator(func): 20 | @wraps(func) 21 | def validate_asap_wrapper(request, *args, **kwargs): 22 | asap_claims = getattr(request, 'asap_claims', None) 23 | if required and not asap_claims: 24 | message = 'Unauthorized: Invalid or missing token' 25 | response = HttpResponse(message, status=401) 26 | response['WWW-Authenticate'] = 'Bearer' 27 | return response 28 | 29 | if asap_claims: 30 | iss = asap_claims['iss'] 31 | if issuers and iss not in issuers: 32 | message = 'Forbidden: Invalid token issuer' 33 | return HttpResponse(message, status=403) 34 | 35 | sub = asap_claims.get('sub') 36 | if subjects and sub not in subjects: 37 | message = 'Forbidden: Invalid token subject' 38 | return HttpResponse(message, status=403) 39 | 40 | return func(request, *args, **kwargs) 41 | 42 | return validate_asap_wrapper 43 | return validate_asap_decorator 44 | 45 | 46 | def requires_asap(issuers=None, subject_should_match_issuer=None, func=None): 47 | """Decorator for Django endpoints to require ASAP 48 | 49 | :param list issuers: *required The 'iss' claims that this endpoint is from. 50 | """ 51 | return with_asap(func=func, 52 | required=True, 53 | issuers=issuers, 54 | subject_should_match_issuer=subject_should_match_issuer) 55 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/contrib/django/middleware.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.utils.deprecation import MiddlewareMixin 3 | 4 | from atlassian_jwt_auth.frameworks.django.middleware import ( 5 | OldStyleASAPMiddleware 6 | ) 7 | 8 | 9 | class ProxiedAsapMiddleware(OldStyleASAPMiddleware, MiddlewareMixin): 10 | """Enable client auth for ASAP-enabled services that are forwarding 11 | non-ASAP client requests. 12 | 13 | This must come before any authentication middleware.""" 14 | 15 | def __init__(self, get_response=None): 16 | super(ProxiedAsapMiddleware, self).__init__() 17 | self.get_response = get_response 18 | 19 | # Rely on this header to tell us if a request has been forwarded 20 | # from an ASAP-enabled service; will overwrite X-Forwarded-For 21 | self.xfwd = getattr(settings, 'ASAP_PROXIED_FORWARDED_FOR_HEADER', 22 | 'HTTP_X_ASAP_FORWARDED_FOR') 23 | 24 | # This header won't always be set, i.e. some users will be anonymous 25 | self.xauth = getattr(settings, 'ASAP_PROXIED_AUTHORIZATION_HEADER', 26 | 'HTTP_X_ASAP_AUTHORIZATION') 27 | 28 | def process_request(self, request): 29 | error_response = super(ProxiedAsapMiddleware, self).process_request( 30 | request 31 | ) 32 | 33 | if error_response: 34 | return error_response 35 | 36 | forwarded_for = request.META.pop(self.xfwd, None) 37 | if forwarded_for is None: 38 | return 39 | 40 | request.asap_forwarded = True 41 | request.META['HTTP_X_FORWARDED_FOR'] = forwarded_for 42 | 43 | asap_auth = request.META.pop('HTTP_AUTHORIZATION', None) 44 | orig_auth = request.META.pop(self.xauth, None) 45 | 46 | # Swap original client header in to allow regular auth middleware 47 | if orig_auth is not None: 48 | request.META['HTTP_AUTHORIZATION'] = orig_auth 49 | if asap_auth is not None: 50 | request.META[self.xauth] = asap_auth 51 | 52 | def process_view(self, request, view_func, view_args, view_kwargs): 53 | if not hasattr(request, 'asap_forwarded'): 54 | return 55 | 56 | # swap headers back into place 57 | asap_auth = request.META.pop(self.xauth, None) 58 | orig_auth = request.META.pop('HTTP_AUTHORIZATION', None) 59 | 60 | if asap_auth is not None: 61 | request.META['HTTP_AUTHORIZATION'] = asap_auth 62 | if orig_auth is not None: 63 | request.META[self.xauth] = orig_auth 64 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/contrib/flask_app/__init__.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from .decorators import requires_asap # noqa 4 | 5 | 6 | warnings.warn( 7 | "The atlassian_jwt_auth.contrib.flask_app package is deprecated in 4.0.0 " 8 | "in favour of atlassian_jwt_auth.frameworks.flask.", 9 | DeprecationWarning, stacklevel=2 10 | ) 11 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/contrib/flask_app/decorators.py: -------------------------------------------------------------------------------- 1 | from atlassian_jwt_auth.frameworks.flask.decorators import with_asap 2 | 3 | 4 | def requires_asap(f, issuers=None, subject_should_match_issuer=None): 5 | """ 6 | Wrapper for Flask endpoints to make them require asap authentication to 7 | access. 8 | """ 9 | 10 | return with_asap(func=f, 11 | required=True, 12 | issuers=issuers, 13 | subject_should_match_issuer=subject_should_match_issuer) 14 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/contrib/requests.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from atlassian_jwt_auth.auth import BaseJWTAuth 4 | 5 | from requests.auth import AuthBase 6 | 7 | 8 | class JWTAuth(AuthBase, BaseJWTAuth): 9 | """Adds a JWT bearer token to the request per the ASAP specification""" 10 | 11 | def __call__(self, r): 12 | r.headers['Authorization'] = self._get_header_value() 13 | return r 14 | 15 | 16 | def create_jwt_auth( 17 | issuer, key_identifier, private_key_pem, audience, **kwargs): 18 | """Instantiate a JWTAuth while creating the signer inline""" 19 | return JWTAuth.create( 20 | issuer, key_identifier, private_key_pem, audience, **kwargs) 21 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/contrib/server/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/asap-authentication-python/20eb8d99162033328bf66ba67d4097fb820e3744/atlassian_jwt_auth/contrib/server/__init__.py -------------------------------------------------------------------------------- /atlassian_jwt_auth/contrib/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/asap-authentication-python/20eb8d99162033328bf66ba67d4097fb820e3744/atlassian_jwt_auth/contrib/tests/__init__.py -------------------------------------------------------------------------------- /atlassian_jwt_auth/contrib/tests/aiohttp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/asap-authentication-python/20eb8d99162033328bf66ba67d4097fb820e3744/atlassian_jwt_auth/contrib/tests/aiohttp/__init__.py -------------------------------------------------------------------------------- /atlassian_jwt_auth/contrib/tests/aiohttp/test_auth.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from atlassian_jwt_auth.contrib.aiohttp.auth import create_jwt_auth, JWTAuth 4 | from atlassian_jwt_auth.tests import utils 5 | from atlassian_jwt_auth.contrib.tests import test_requests 6 | 7 | 8 | class BaseAuthTest(test_requests.BaseRequestsTest): 9 | """ tests for the contrib.aiohttp.JWTAuth class """ 10 | auth_cls = JWTAuth 11 | 12 | def _get_auth_header(self, auth): 13 | return auth.encode().encode('latin1') 14 | 15 | def create_jwt_auth(self, *args, **kwargs): 16 | return create_jwt_auth(*args, **kwargs) 17 | 18 | 19 | class RequestsRS256Test(BaseAuthTest, 20 | utils.RS256KeyTestMixin, 21 | unittest.TestCase): 22 | pass 23 | 24 | 25 | class RequestsES256Test(BaseAuthTest, 26 | utils.ES256KeyTestMixin, 27 | unittest.TestCase): 28 | pass 29 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/contrib/tests/aiohttp/test_public_key_provider.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest import mock 3 | 4 | import aiohttp 5 | from multidict import CIMultiDict 6 | 7 | try: 8 | from unittest import IsolatedAsyncioTestCase as TestCase 9 | from unittest.mock import AsyncMock as CoroutineMock 10 | from unittest.mock import Mock 11 | except ImportError: 12 | from asynctest import CoroutineMock, TestCase, Mock 13 | 14 | from atlassian_jwt_auth.contrib.aiohttp import HTTPSPublicKeyRetriever 15 | from atlassian_jwt_auth.key import PEM_FILE_TYPE 16 | from atlassian_jwt_auth.tests import utils 17 | from atlassian_jwt_auth.tests.test_public_key_provider import ( 18 | get_expected_and_os_proxies_dict, 19 | ) 20 | 21 | 22 | class DummyHTTPSPublicKeyRetriever(HTTPSPublicKeyRetriever): 23 | 24 | def set_headers(self, headers): 25 | self._session.get.return_value.headers.update(headers) 26 | 27 | def set_text(self, text): 28 | self._session.get.return_value.text.return_value = text 29 | 30 | def _get_session(self): 31 | session = Mock(spec=aiohttp.ClientSession) 32 | session.attach_mock(CoroutineMock(), 'get') 33 | 34 | resp = session.get.return_value 35 | resp.headers = CIMultiDict({"content-type": PEM_FILE_TYPE}) 36 | resp.text = CoroutineMock(return_value='i-am-a-public-key') 37 | resp.raise_for_status = Mock(name='raise_for_status') 38 | return session 39 | 40 | 41 | class BaseHTTPSPublicKeyRetrieverTestMixin(object): 42 | """Tests for aiohttp.HTTPSPublicKeyRetriever class for RS256 algorithm""" 43 | 44 | def setUp(self): 45 | self._private_key_pem = self.get_new_private_key_in_pem_format() 46 | self._public_key_pem = utils.get_public_key_pem_for_private_key_pem( 47 | self._private_key_pem) 48 | self.base_url = 'https://example.com' 49 | 50 | async def test_retrieve(self): 51 | """Check if retrieve method returns public key""" 52 | retriever = DummyHTTPSPublicKeyRetriever(self.base_url) 53 | retriever.set_text(self._public_key_pem) 54 | self.assertEqual( 55 | await retriever.retrieve('example/eg'), 56 | self._public_key_pem) 57 | 58 | async def test_retrieve_with_charset_in_content_type_h(self): 59 | """Check if retrieve method correctly checks content-type""" 60 | headers = {'content-type': 'application/x-pem-file;charset=UTF-8'} 61 | retriever = DummyHTTPSPublicKeyRetriever(self.base_url) 62 | retriever.set_text(self._public_key_pem) 63 | retriever.set_headers(headers) 64 | 65 | self.assertEqual( 66 | await retriever.retrieve('example/eg'), 67 | self._public_key_pem) 68 | 69 | async def test_retrieve_fails_with_different_content_type(self): 70 | """ 71 | Check if retrieve method raises an error for incorrect content-type 72 | """ 73 | headers = {'content-type': 'different/not-supported'} 74 | retriever = DummyHTTPSPublicKeyRetriever(self.base_url) 75 | retriever.set_text(self._public_key_pem) 76 | retriever.set_headers(headers) 77 | 78 | with self.assertRaises(ValueError): 79 | await retriever.retrieve('example/eg') 80 | 81 | async def test_retrieve_session_uses_env_proxy(self): 82 | """ tests that the underlying session makes use of environmental 83 | proxy configured. 84 | """ 85 | proxy_location = 'https://example.proxy' 86 | key_id = 'example/eg' 87 | expected_proxies, proxy_dict = get_expected_and_os_proxies_dict( 88 | proxy_location) 89 | with mock.patch.dict(os.environ, proxy_dict, clear=True): 90 | retriever = DummyHTTPSPublicKeyRetriever(self.base_url) 91 | self.assertEqual(retriever._proxies, expected_proxies) 92 | await retriever.retrieve(key_id) 93 | retriever._session.get.assert_called_once_with( 94 | f'{self.base_url}/{key_id}', headers={'accept': PEM_FILE_TYPE}, 95 | proxy=expected_proxies[self.base_url.split(':')[0]] 96 | ) 97 | 98 | 99 | class RS256HTTPSPublicKeyRetrieverTest(utils.RS256KeyTestMixin, 100 | BaseHTTPSPublicKeyRetrieverTestMixin, 101 | TestCase): 102 | """Tests for aiohttp.HTTPSPublicKeyRetriever class for RS256 algorithm""" 103 | 104 | 105 | class ES256HTTPSPublicKeyRetrieverTest(utils.RS256KeyTestMixin, 106 | BaseHTTPSPublicKeyRetrieverTestMixin, 107 | TestCase): 108 | """Tests for aiohttp.HTTPSPublicKeyRetriever class for ES256 algorithm""" 109 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/contrib/tests/aiohttp/test_verifier.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | try: 4 | from unittest import IsolatedAsyncioTestCase as TestCase 5 | from unittest.mock import AsyncMock as CoroutineMock 6 | except ImportError: 7 | from asynctest import TestCase, CoroutineMock 8 | 9 | from atlassian_jwt_auth.contrib.aiohttp import (HTTPSPublicKeyRetriever, 10 | JWTAuthVerifier) 11 | from atlassian_jwt_auth.tests import test_verifier, utils 12 | 13 | 14 | class SyncJWTAuthVerifier(JWTAuthVerifier): 15 | 16 | def __init__(self, *args, loop=None, **kwargs): 17 | if loop is None: 18 | loop = asyncio.get_event_loop() 19 | self.loop = loop 20 | super().__init__(*args, **kwargs) 21 | 22 | def verify_jwt(self, *args, **kwargs): 23 | return self.loop.run_until_complete( 24 | super().verify_jwt(*args, **kwargs) 25 | ) 26 | 27 | 28 | class JWTAuthVerifierTestMixin(test_verifier.BaseJWTAuthVerifierTest): 29 | loop = None 30 | 31 | def _setup_mock_public_key_retriever(self, pub_key_pem): 32 | m_public_key_ret = CoroutineMock(spec=HTTPSPublicKeyRetriever) 33 | m_public_key_ret.retrieve.return_value = pub_key_pem.decode() 34 | return m_public_key_ret 35 | 36 | def _setup_jwt_auth_verifier(self, pub_key_pem, **kwargs): 37 | m_public_key_ret = self._setup_mock_public_key_retriever(pub_key_pem) 38 | return SyncJWTAuthVerifier(m_public_key_ret, loop=self.loop, **kwargs) 39 | 40 | 41 | class JWTAuthVerifierRS256Test( 42 | utils.RS256KeyTestMixin, JWTAuthVerifierTestMixin, TestCase): 43 | """Tests for aiohttp.JWTAuthVerifier class for RS256 algorithm""" 44 | 45 | 46 | class JWTAuthVerifierES256Test( 47 | utils.ES256KeyTestMixin, JWTAuthVerifierTestMixin, TestCase): 48 | """Tests for aiohttp.JWTAuthVerifier class for ES256 algorithm""" 49 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/contrib/tests/test_requests.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from datetime import timedelta 3 | 4 | import jwt 5 | from requests import Request 6 | 7 | import atlassian_jwt_auth 8 | from atlassian_jwt_auth.tests import utils 9 | from atlassian_jwt_auth.contrib.requests import JWTAuth, create_jwt_auth 10 | 11 | 12 | class BaseRequestsTest(object): 13 | 14 | """ tests for the contrib.requests.JWTAuth class """ 15 | auth_cls = JWTAuth 16 | 17 | def setUp(self): 18 | self._private_key_pem = self.get_new_private_key_in_pem_format() 19 | self._public_key_pem = utils.get_public_key_pem_for_private_key_pem( 20 | self._private_key_pem) 21 | 22 | def assert_authorization_header_is_valid(self, auth): 23 | """ asserts that the given request contains a valid Authorization 24 | header. 25 | """ 26 | auth_header = self._get_auth_header(auth) 27 | bearer = auth_header.split(b' ')[1] 28 | # Decode the JWT (verifying the signature and aud match) 29 | # an exception is thrown if this fails 30 | algorithms = atlassian_jwt_auth.get_permitted_algorithm_names() 31 | return jwt.decode(bearer, self._public_key_pem.decode(), 32 | audience='audience', algorithms=algorithms) 33 | 34 | def _get_auth_header(self, auth): 35 | request = auth(Request()) 36 | auth_header = request.headers['Authorization'] 37 | return auth_header 38 | 39 | def create_jwt_auth(self, *args, **kwargs): 40 | return create_jwt_auth(*args, **kwargs) 41 | 42 | def test_JWTAuth_make_authenticated_request(self): 43 | """Verify a valid Authorization header is added by JWTAuth""" 44 | jwt_auth_signer = atlassian_jwt_auth.create_signer( 45 | 'issuer', 46 | 'issuer/key', 47 | self._private_key_pem.decode(), 48 | algorithm=self.algorithm) 49 | auth = self.auth_cls(jwt_auth_signer, 'audience') 50 | self.assert_authorization_header_is_valid(auth) 51 | 52 | def test_create_jwt_auth(self): 53 | """Verify a valid Authorization header is added by JWTAuth""" 54 | auth = self.create_jwt_auth('issuer', 'issuer/key', 55 | self._private_key_pem.decode(), 'audience', 56 | algorithm=self.algorithm) 57 | self.assert_authorization_header_is_valid(auth) 58 | 59 | def test_create_jwt_auth_with_additional_claims(self): 60 | """ Verify a Valid Authorization header is added by JWTAuth and 61 | contains the additional claims when provided. 62 | """ 63 | jwt_auth_signer = atlassian_jwt_auth.create_signer( 64 | 'issuer', 65 | 'issuer/key', 66 | self._private_key_pem.decode(), 67 | algorithm=self.algorithm) 68 | auth = self.auth_cls(jwt_auth_signer, 'audience', 69 | additional_claims={'example': 'claim'}) 70 | token = self.assert_authorization_header_is_valid(auth) 71 | self.assertEqual(token.get('example'), 'claim') 72 | 73 | def test_do_not_reuse_jwts(self): 74 | auth = self.create_jwt_auth('issuer', 'issuer/key', 75 | self._private_key_pem.decode(), 'audience', 76 | algorithm=self.algorithm) 77 | auth_header = self._get_auth_header(auth) 78 | self.assertNotEqual(auth_header, self._get_auth_header(auth)) 79 | 80 | def test_reuse_jwts(self): 81 | auth = self.create_jwt_auth('issuer', 'issuer/key', 82 | self._private_key_pem.decode(), 'audience', 83 | algorithm=self.algorithm, reuse_jwts=True) 84 | auth_header = self._get_auth_header(auth) 85 | self.assertEqual(auth_header, self._get_auth_header(auth)) 86 | 87 | def test_do_not_reuse_jwt_if_audience_changes(self): 88 | auth = self.create_jwt_auth('issuer', 'issuer/key', 89 | self._private_key_pem.decode(), 'audience', 90 | algorithm=self.algorithm, reuse_jwts=True) 91 | auth_header = self._get_auth_header(auth) 92 | auth._audience = 'not-' + auth._audience 93 | self.assertNotEqual(auth_header, self._get_auth_header(auth)) 94 | 95 | def test_do_not_reuse_jwt_if_issuer_changes(self): 96 | auth = self.create_jwt_auth('issuer', 'issuer/key', 97 | self._private_key_pem.decode(), 'audience', 98 | algorithm=self.algorithm, reuse_jwts=True) 99 | auth_header = self._get_auth_header(auth) 100 | auth._signer.issuer = 'not-' + auth._signer.issuer 101 | self.assertNotEqual(auth_header, self._get_auth_header(auth)) 102 | 103 | def test_do_not_reuse_jwt_if_lifetime_changes(self): 104 | auth = self.create_jwt_auth('issuer', 'issuer/key', 105 | self._private_key_pem.decode(), 'audience', 106 | algorithm=self.algorithm, reuse_jwts=True) 107 | auth_header = self._get_auth_header(auth) 108 | auth._signer.lifetime = auth._signer.lifetime - timedelta(seconds=1) 109 | self.assertNotEqual(auth_header, self._get_auth_header(auth)) 110 | 111 | def test_do_not_reuse_jwt_if_subject_changes(self): 112 | auth = self.create_jwt_auth('issuer', 'issuer/key', 113 | self._private_key_pem.decode(), 'audience', 114 | algorithm=self.algorithm, reuse_jwts=True, 115 | subject='subject') 116 | auth_header = self._get_auth_header(auth) 117 | auth._signer.subject = 'not-' + auth._signer.subject 118 | self.assertNotEqual(auth_header, self._get_auth_header(auth)) 119 | 120 | def test_do_not_reuse_jwt_if_additional_claims_change(self): 121 | auth = self.create_jwt_auth('issuer', 'issuer/key', 122 | self._private_key_pem.decode(), 'audience', 123 | algorithm=self.algorithm, reuse_jwts=True) 124 | auth_header = self._get_auth_header(auth) 125 | auth._additional_claims['foo'] = 'bar' 126 | self.assertNotEqual(auth_header, self._get_auth_header(auth)) 127 | 128 | def test_reuse_jwt_with_additional_claims(self): 129 | # calculating the cache key with additional claims is non-trivial 130 | auth = self.create_jwt_auth('issuer', 'issuer/key', 131 | self._private_key_pem.decode(), 'audience', 132 | algorithm=self.algorithm, reuse_jwts=True) 133 | auth._additional_claims['foo'] = 'bar' 134 | auth._additional_claims['fool'] = 'blah' 135 | auth._additional_claims['foot'] = 'quux' 136 | auth_header = self._get_auth_header(auth) 137 | self.assertEqual(auth_header, self._get_auth_header(auth)) 138 | 139 | 140 | class RequestsRS256Test(BaseRequestsTest, 141 | utils.RS256KeyTestMixin, 142 | unittest.TestCase): 143 | pass 144 | 145 | 146 | class RequestsES256Test(BaseRequestsTest, 147 | utils.ES256KeyTestMixin, 148 | unittest.TestCase): 149 | pass 150 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/contrib/tests/utils.py: -------------------------------------------------------------------------------- 1 | import atlassian_jwt_auth 2 | 3 | 4 | def get_static_retriever_class(keys): 5 | 6 | class StaticPublicKeyRetriever(object): 7 | """ Retrieves a key from a static list of public keys 8 | (for use in tests only) """ 9 | 10 | def __init__(self, *args, **kwargs): 11 | self.keys = keys 12 | 13 | def retrieve(self, key_identifier, **requests_kwargs): 14 | return self.keys[key_identifier.key_id] 15 | 16 | return StaticPublicKeyRetriever 17 | 18 | 19 | def static_verifier(keys): 20 | return atlassian_jwt_auth.JWTAuthVerifier( 21 | get_static_retriever_class(keys)() 22 | ) 23 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/exceptions.py: -------------------------------------------------------------------------------- 1 | class _WrappedException(object): 2 | """Allow wrapping exceptions in a new class while preserving the original 3 | as an attribute. 4 | 5 | Note that while Python 2 and 3 both have reasonable ways to handle this, 6 | they're mutually incompatible. This is a simple, portable approach that 7 | should be sufficient for most use cases. 8 | """ 9 | 10 | def __init__(self, *args, **kwargs): 11 | wrapped_args = [arg for arg in args] 12 | 13 | if args: 14 | orig = args[0] 15 | if isinstance(orig, Exception): 16 | 17 | wrapped_args[0] = str(orig) 18 | self.original_exception = getattr(orig, 'original_exception', 19 | orig) 20 | super(_WrappedException, self).__init__(*wrapped_args, **kwargs) 21 | 22 | 23 | class _WithStatus(object): 24 | """Allow an optional status_code attribute on wrapped exceptions. 25 | 26 | This should allow inspecting HTTP-related errors without having to know 27 | details about the HTTP client library. 28 | """ 29 | 30 | def __init__(self, *args, **kwargs): 31 | status_code = kwargs.pop('status_code', None) 32 | super(_WithStatus, self).__init__(*args, **kwargs) 33 | self.status_code = status_code 34 | 35 | 36 | class ASAPAuthenticationException(_WrappedException, ValueError): 37 | """Base class for exceptions raised by this library 38 | 39 | Inherits from ValueError to maintain backward compatibility 40 | with clients that caught ValueError previously. 41 | """ 42 | 43 | 44 | class PublicKeyRetrieverException(_WithStatus, ASAPAuthenticationException): 45 | """Raise when there are issues retrieving the public key""" 46 | 47 | 48 | class PrivateKeyRetrieverException(_WithStatus, ASAPAuthenticationException): 49 | """Raise when there are issues retrieving the private key""" 50 | 51 | 52 | class KeyIdentifierException(ASAPAuthenticationException): 53 | """Raise when there are issues validating the key identifier""" 54 | 55 | 56 | class JtiUniquenessException(ASAPAuthenticationException): 57 | """Raise when a JTI is seen more than once. """ 58 | 59 | 60 | class SubjectDoesNotMatchIssuerException(ASAPAuthenticationException): 61 | """Raise when the subject and issuer differ. """ 62 | 63 | 64 | class NoTokenProvidedError(ASAPAuthenticationException): 65 | """Raise when no token is provided""" 66 | pass 67 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/frameworks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/asap-authentication-python/20eb8d99162033328bf66ba67d4097fb820e3744/atlassian_jwt_auth/frameworks/__init__.py -------------------------------------------------------------------------------- /atlassian_jwt_auth/frameworks/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/asap-authentication-python/20eb8d99162033328bf66ba67d4097fb820e3744/atlassian_jwt_auth/frameworks/common/__init__.py -------------------------------------------------------------------------------- /atlassian_jwt_auth/frameworks/common/asap.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from jwt.exceptions import InvalidIssuerError, InvalidTokenError 4 | 5 | from atlassian_jwt_auth.exceptions import ( 6 | PublicKeyRetrieverException, 7 | NoTokenProvidedError, 8 | JtiUniquenessException, 9 | SubjectDoesNotMatchIssuerException, 10 | ) 11 | 12 | 13 | def _process_asap_token(request, backend, settings, verifier=None): 14 | """ Verifies an ASAP token, validates the claims, and returns an error 15 | response""" 16 | logger = logging.getLogger('asap') 17 | token = backend.get_asap_token(request) 18 | error_response = None 19 | if token is None and not settings.ASAP_REQUIRED and ( 20 | settings.ASAP_REQUIRED is not None): 21 | return 22 | try: 23 | if token is None: 24 | raise NoTokenProvidedError 25 | if verifier is None: 26 | verifier = backend.get_verifier(settings=settings) 27 | asap_claims = verifier.verify_jwt( 28 | token, 29 | settings.ASAP_VALID_AUDIENCE, 30 | leeway=settings.ASAP_VALID_LEEWAY, 31 | ) 32 | 33 | _verify_issuers(asap_claims, settings.ASAP_VALID_ISSUERS) 34 | backend.set_asap_claims_for_request(request, asap_claims) 35 | except NoTokenProvidedError: 36 | logger.info('No token provided') 37 | error_response = backend.get_401_response( 38 | 'Unauthorized', request=request 39 | ) 40 | except PublicKeyRetrieverException as e: 41 | if e.status_code not in (403, 404): 42 | # Any error other than "not found" is a problem and should 43 | # be dealt with elsewhere. 44 | # Note that we treat 403 like 404 to account for the fact 45 | # that a server configured to secure directory listings 46 | # will return 403 for a missing file to avoid leaking 47 | # information. 48 | raise 49 | logger.warning('Could not retrieve the matching public key') 50 | error_response = backend.get_401_response( 51 | 'Unauthorized: Key not found', request=request 52 | ) 53 | except InvalidIssuerError: 54 | logger.warning('Invalid token - issuer') 55 | error_response = backend.get_403_response( 56 | 'Forbidden: Invalid token issuer', request=request 57 | ) 58 | except InvalidTokenError: 59 | logger.warning('Invalid token') 60 | error_response = backend.get_401_response( 61 | 'Unauthorized: Invalid token', request=request 62 | ) 63 | except JtiUniquenessException: 64 | logger.warning('Invalid token - duplicate jti') 65 | error_response = backend.get_401_response( 66 | 'Unauthorized: Invalid token - duplicate jti', request=request 67 | ) 68 | except SubjectDoesNotMatchIssuerException: 69 | logger.warning('Invalid token - subject and issuer do not match') 70 | error_response = backend.get_401_response( 71 | 'Unauthorized: Subject and Issuer do not match', request=request 72 | ) 73 | except Exception: 74 | logger.exception('An error occured while checking an asap token') 75 | raise 76 | 77 | if error_response is not None and settings.ASAP_REQUIRED: 78 | return error_response 79 | 80 | 81 | def _verify_issuers(asap_claims, issuers=None): 82 | """Verify that the issuer in the claims is valid and is expected.""" 83 | claim_iss = asap_claims.get('iss') 84 | if issuers and claim_iss not in issuers: 85 | raise InvalidIssuerError 86 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/frameworks/common/backend.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod, abstractproperty 2 | from functools import lru_cache 3 | 4 | from atlassian_jwt_auth import HTTPSPublicKeyRetriever, JWTAuthVerifier 5 | 6 | from .utils import SettingsDict 7 | 8 | 9 | @lru_cache(maxsize=20) 10 | def _get_verifier(settings): 11 | """ This has been extracted out of Backend to avoid possible memory 12 | leaks via retained instance references. 13 | """ 14 | retriever = settings.ASAP_KEY_RETRIEVER_CLASS( 15 | base_url=settings.ASAP_PUBLICKEY_REPOSITORY 16 | ) 17 | kwargs = {} 18 | if settings.ASAP_SUBJECT_SHOULD_MATCH_ISSUER is not None: 19 | kwargs = {'subject_should_match_issuer': 20 | settings.ASAP_SUBJECT_SHOULD_MATCH_ISSUER} 21 | if settings.ASAP_CHECK_JTI_UNIQUENESS is not None: 22 | kwargs['check_jti_uniqueness'] = settings.ASAP_CHECK_JTI_UNIQUENESS 23 | return JWTAuthVerifier( 24 | retriever, 25 | **kwargs 26 | ) 27 | 28 | 29 | class Backend(): 30 | """Abstract class representing a web framework backend 31 | 32 | Backends allow specific implementation details of web frameworks to be 33 | abstracted away from the underlying logic of ASAP. 34 | """ 35 | __metaclass__ = ABCMeta 36 | 37 | default_headers_401 = {'WWW-Authenticate': 'Bearer'} 38 | default_settings = { 39 | # The class to be instantiated to retrieve public keys 40 | 'ASAP_KEY_RETRIEVER_CLASS': HTTPSPublicKeyRetriever, 41 | 42 | # The repository URL where the key retriever can fetch public keys 43 | 'ASAP_PUBLICKEY_REPOSITORY': None, 44 | 45 | # Whether or not ASAP authentication is required 46 | # This is primarily useful when phasing in ASAP authentication 47 | 'ASAP_REQUIRED': True, 48 | 49 | # The valid audience value expected when authenticating tokens 50 | 'ASAP_VALID_AUDIENCE': None, 51 | 52 | # The amount of leeway to apply when evaluating token expiration 53 | # timestamps 54 | 'ASAP_VALID_LEEWAY': 0, 55 | 56 | # An iterable of valid token issuers allowed to authenticate 57 | # (this can be overridden at the decorator level) 58 | 'ASAP_VALID_ISSUERS': None, 59 | 60 | # Enforce that the ASAP subject must match the issuer 61 | 'ASAP_SUBJECT_SHOULD_MATCH_ISSUER': None, 62 | 63 | # Enforce that tokens have a unique JTI 64 | # Set this to True to enforce JTI uniqueness checking. 65 | 'ASAP_CHECK_JTI_UNIQUENESS': None, 66 | } 67 | 68 | @abstractmethod 69 | def get_authorization_header(self, request=None): 70 | pass 71 | 72 | @abstractmethod 73 | def get_401_response(self, data=None, headers=None, request=None): 74 | pass 75 | 76 | @abstractmethod 77 | def get_403_response(self, data=None, headers=None, request=None): 78 | pass 79 | 80 | @abstractmethod 81 | def set_asap_claims_for_request(self, request, claims): 82 | pass 83 | 84 | @abstractproperty 85 | def settings(self): 86 | return SettingsDict(self.default_settings) 87 | 88 | def get_asap_token(self, request): 89 | auth_header = self.get_authorization_header(request) 90 | 91 | if auth_header is None: 92 | return None 93 | 94 | if isinstance(auth_header, str): 95 | # Per PEP-3333, headers must be in ISO-8859-1 or use an RFC-2047 96 | # MIME encoding. We don't really care about MIME encoded 97 | # headers, but some libraries allow sending bytes (Django tests) 98 | # and some (requests) always send str so we need to convert if 99 | # that is the case to properly support Python 3. 100 | auth_header = auth_header.encode(encoding='iso-8859-1') 101 | 102 | auth_values = auth_header.split(b' ') 103 | if len(auth_values) != 2 or auth_values[0].lower() != b'bearer': 104 | return None 105 | 106 | return auth_values[1] 107 | 108 | def get_verifier(self, settings=None): 109 | """Returns a verifier for ASAP JWT tokens""" 110 | if settings is None: 111 | settings = self.settings 112 | return self._get_verifier(settings) 113 | 114 | def _get_verifier(self, settings): 115 | return _get_verifier(settings) 116 | 117 | def _process_settings(self, settings): 118 | valid_issuers = settings.get('ASAP_VALID_ISSUERS') 119 | if valid_issuers: 120 | settings['ASAP_VALID_ISSUERS'] = set(valid_issuers) 121 | 122 | valid_aud = settings.get('ASAP_VALID_AUDIENCE') 123 | if valid_aud and isinstance(valid_aud, list): 124 | settings['ASAP_VALID_AUDIENCE'] = set(valid_aud) 125 | 126 | return SettingsDict(settings) 127 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/frameworks/common/decorators.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from jwt.exceptions import InvalidIssuerError, InvalidTokenError 3 | 4 | from .asap import _process_asap_token, _verify_issuers 5 | from .utils import SettingsDict 6 | 7 | 8 | def _with_asap(func=None, backend=None, issuers=None, required=True, 9 | subject_should_match_issuer=None): 10 | if backend is None: 11 | raise ValueError( 12 | 'Invalid value for backend. Use a subclass instead.' 13 | ) 14 | 15 | def with_asap_decorator(func): 16 | @wraps(func) 17 | def with_asap_wrapper(*args, **kwargs): 18 | settings = _update_settings_from_kwargs( 19 | backend.settings, 20 | issuers=issuers, required=required, 21 | subject_should_match_issuer=subject_should_match_issuer 22 | ) 23 | 24 | request = None 25 | if len(args) > 0: 26 | request = args[0] 27 | 28 | error_response = _process_asap_token( 29 | request, backend, settings 30 | ) 31 | 32 | if error_response is not None: 33 | return error_response 34 | 35 | return func(*args, **kwargs) 36 | 37 | return with_asap_wrapper 38 | 39 | if callable(func): 40 | return with_asap_decorator(func) 41 | 42 | return with_asap_decorator 43 | 44 | 45 | def _restrict_asap(func=None, backend=None, issuers=None, 46 | required=True, subject_should_match_issuer=None): 47 | """Decorator to allow endpoint-specific ASAP authorization, assuming ASAP 48 | authentication has already occurred. 49 | """ 50 | 51 | def restrict_asap_decorator(func): 52 | @wraps(func) 53 | def restrict_asap_wrapper(request, *args, **kwargs): 54 | settings = _update_settings_from_kwargs( 55 | backend.settings, 56 | issuers=issuers, required=required, 57 | subject_should_match_issuer=subject_should_match_issuer 58 | ) 59 | asap_claims = getattr(request, 'asap_claims', None) 60 | error_response = None 61 | 62 | if required and not asap_claims: 63 | return backend.get_401_response( 64 | 'Unauthorized', request=request 65 | ) 66 | 67 | try: 68 | _verify_issuers(asap_claims, settings.ASAP_VALID_ISSUERS) 69 | except InvalidIssuerError: 70 | error_response = backend.get_403_response( 71 | 'Forbidden: Invalid token issuer', request=request 72 | ) 73 | except InvalidTokenError: 74 | error_response = backend.get_401_response( 75 | 'Unauthorized: Invalid token', request=request 76 | ) 77 | 78 | if error_response and required: 79 | return error_response 80 | 81 | return func(request, *args, **kwargs) 82 | 83 | return restrict_asap_wrapper 84 | 85 | if callable(func): 86 | return restrict_asap_decorator(func) 87 | 88 | return restrict_asap_decorator 89 | 90 | 91 | def _update_settings_from_kwargs(settings, issuers=None, required=True, 92 | subject_should_match_issuer=None): 93 | settings = settings.copy() 94 | 95 | if issuers is not None: 96 | settings['ASAP_VALID_ISSUERS'] = set(issuers) 97 | 98 | if required is not None: 99 | settings['ASAP_REQUIRED'] = required 100 | 101 | if subject_should_match_issuer is not None: 102 | settings['ASAP_SUBJECT_SHOULD_MATCH_ISSUER'] = ( 103 | subject_should_match_issuer 104 | ) 105 | 106 | return SettingsDict(settings) 107 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/frameworks/common/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/asap-authentication-python/20eb8d99162033328bf66ba67d4097fb820e3744/atlassian_jwt_auth/frameworks/common/tests/__init__.py -------------------------------------------------------------------------------- /atlassian_jwt_auth/frameworks/common/tests/test_decorators.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/asap-authentication-python/20eb8d99162033328bf66ba67d4097fb820e3744/atlassian_jwt_auth/frameworks/common/tests/test_decorators.py -------------------------------------------------------------------------------- /atlassian_jwt_auth/frameworks/common/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from atlassian_jwt_auth.frameworks.common import utils 4 | 5 | 6 | class SettingsDictTest(unittest.TestCase): 7 | """ Tests for the SettingsDict class. """ 8 | 9 | def test_hash(self): 10 | """ Test that SettingsDict instances can be hashed. """ 11 | dictionary_one = {'a': 'b', '3': set([1]), 'f': None} 12 | dictionary_two = {'a': 'b', '3': set([1]), 'f': None} 13 | dictionary_three = {'a': 'b', '3': set([1]), 'diff': '333'} 14 | settings_one = utils.SettingsDict(dictionary_one) 15 | settings_two = utils.SettingsDict(dictionary_two) 16 | settings_three = utils.SettingsDict(dictionary_three) 17 | self.assertEqual(settings_one, settings_two) 18 | self.assertEqual(hash(settings_one), hash(settings_two)) 19 | self.assertNotEqual(settings_one, settings_three) 20 | self.assertNotEqual(hash(settings_one), hash(settings_three)) 21 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/frameworks/common/utils.py: -------------------------------------------------------------------------------- 1 | class SettingsDict(dict): 2 | def __getattr__(self, name): 3 | if name not in self: 4 | raise AttributeError 5 | 6 | return self[name] 7 | 8 | def __setitem__(self, key, value): 9 | raise AttributeError('SettingsDict properties are immutable') 10 | 11 | def _hash_key(self): 12 | keys_and_values = [] 13 | for key, value in self.items(): 14 | if isinstance(value, set): 15 | value = frozenset(value) 16 | keys_and_values.append("%s %s" % (key, hash(value))) 17 | return frozenset(keys_and_values) 18 | 19 | def __hash__(self): 20 | return hash(self._hash_key()) 21 | 22 | def __eq__(self, other): 23 | return hash(self) == hash(other) 24 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/frameworks/django/__init__.py: -------------------------------------------------------------------------------- 1 | from .decorators import with_asap, restrict_asap # noqa 2 | from .middleware import asap_middleware, OldStyleASAPMiddleware # noqa 3 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/frameworks/django/backend.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings as django_settings 2 | from django.http import HttpResponse, HttpResponseForbidden 3 | 4 | from ..common.backend import Backend 5 | 6 | 7 | class DjangoBackend(Backend): 8 | def get_authorization_header(self, request=None): 9 | if request is None: 10 | raise ValueError('No request available') 11 | 12 | return request.META.get('HTTP_AUTHORIZATION', b'') 13 | 14 | def get_401_response(self, data=None, headers=None, request=None): 15 | if headers is None: 16 | headers = {} 17 | 18 | headers.update(self.default_headers_401) 19 | 20 | response = HttpResponse(content=data, status=401) 21 | for k, v in headers.items(): 22 | response[k] = v 23 | 24 | return response 25 | 26 | def get_403_response(self, data=None, headers=None, request=None): 27 | if headers is None: 28 | headers = {} 29 | 30 | response = HttpResponseForbidden(data) 31 | for k, v in headers.items(): 32 | response[k] = v 33 | 34 | return response 35 | 36 | def set_asap_claims_for_request(self, request, claims): 37 | request.asap_claims = claims 38 | 39 | @property 40 | def settings(self): 41 | settings = {} 42 | settings.update(self.default_settings) 43 | 44 | for k in settings.keys(): 45 | value = getattr(django_settings, k, None) 46 | if value is None: 47 | continue 48 | 49 | settings[k] = value 50 | 51 | return self._process_settings(settings) 52 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/frameworks/django/decorators.py: -------------------------------------------------------------------------------- 1 | from ..common.decorators import _with_asap, _restrict_asap 2 | from .backend import DjangoBackend 3 | 4 | 5 | def with_asap(func=None, issuers=None, required=None, 6 | subject_should_match_issuer=None): 7 | """Decorator to allow endpoint-specific ASAP authentication. 8 | 9 | If authentication fails, a 401 or 403 response will be returned. Otherwise, 10 | the decorated function will be executed. 11 | 12 | The ASAP claimset will be set on request.asap_claims for further 13 | inspection later in the request lifecycle. 14 | 15 | :param list func: The view to decorate. 16 | :param list issuers: A list of valid token issuers that can access this 17 | endpoint. 18 | :param boolean required: Whether or not to require ASAP on this endpoint. 19 | :param boolean subject_should_match_issuer: Indicate whether the subject 20 | must match the issuer for a 21 | token to be considered valid. 22 | """ 23 | return _with_asap( 24 | func, DjangoBackend(), issuers, required, 25 | subject_should_match_issuer 26 | ) 27 | 28 | 29 | def restrict_asap(func=None, backend=None, issuers=None, 30 | required=True, subject_should_match_issuer=None): 31 | """Decorator to allow endpoint-specific ASAP authorization policies. 32 | 33 | This decorator assumes that request.asap_claims has previously been set by 34 | the asap_middleware. 35 | 36 | If the token does not meet the requirements imposed by the decorator, a 401 37 | or 403 response will be returned. Otherwise, the decorated function will be 38 | executed. 39 | 40 | :param list func: The view to decorate. 41 | :param list issuers: A list of valid token issuers that can access this 42 | endpoint. 43 | :param boolean required: Whether or not to require ASAP on this endpoint. 44 | :param boolean subject_should_match_issuer: Indicate whether the subject 45 | must match the issuer for a 46 | token to be considered valid. 47 | """ 48 | return _restrict_asap( 49 | func, DjangoBackend(), issuers, required, 50 | subject_should_match_issuer=None 51 | ) 52 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/frameworks/django/middleware.py: -------------------------------------------------------------------------------- 1 | from ..common.asap import _process_asap_token 2 | from .backend import DjangoBackend 3 | 4 | 5 | def asap_middleware(get_response): 6 | """Middleware to enable ASAP for all requests""" 7 | backend = DjangoBackend() 8 | settings = backend.settings 9 | _verifier = backend.get_verifier(settings=settings) 10 | 11 | def middleware(request): 12 | error_response = _process_asap_token(request, backend, settings, 13 | verifier=_verifier) 14 | if error_response is not None: 15 | return error_response 16 | 17 | return get_response(request) 18 | 19 | return middleware 20 | 21 | 22 | class OldStyleASAPMiddleware(object): 23 | """Middleware to enable ASAP for all requests (for legacy applications 24 | using MIDDLEWARE_CLASSES)""" 25 | 26 | def __init__(self): 27 | self.backend = DjangoBackend() 28 | self.settings = self.backend.settings 29 | self._verifier = self.backend.get_verifier(settings=self.settings) 30 | 31 | def process_request(self, request): 32 | error_response = _process_asap_token( 33 | request, self.backend, self.settings, verifier=self._verifier 34 | ) 35 | if error_response is not None: 36 | return error_response 37 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/frameworks/django/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/asap-authentication-python/20eb8d99162033328bf66ba67d4097fb820e3744/atlassian_jwt_auth/frameworks/django/tests/__init__.py -------------------------------------------------------------------------------- /atlassian_jwt_auth/frameworks/django/tests/settings.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 4 | BASE_DIR = Path(__file__).resolve().parent.parent 5 | 6 | 7 | # Quick-start development settings - unsuitable for production 8 | # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ 9 | 10 | # SECURITY WARNING: keep the secret key used in production secret! 11 | SECRET_KEY = 'django-insecure-5i@^w(cnsaqrx*3co@!&wd' \ 12 | 'vgp4wflkgw$qt#9j@e#tyxg!wdzd' 13 | 14 | # SECURITY WARNING: don't run with debug turned on in production! 15 | DEBUG = True 16 | 17 | ALLOWED_HOSTS = ['*'] 18 | 19 | 20 | # Application definition 21 | 22 | INSTALLED_APPS = [ 23 | 'django.contrib.admin', 24 | 'django.contrib.auth', 25 | 'django.contrib.contenttypes', 26 | 'django.contrib.sessions', 27 | 'django.contrib.messages', 28 | 'django.contrib.staticfiles', 29 | ] 30 | 31 | MIDDLEWARE = [ 32 | 'django.middleware.security.SecurityMiddleware', 33 | 'django.contrib.sessions.middleware.SessionMiddleware', 34 | 'django.middleware.common.CommonMiddleware', 35 | 'django.middleware.csrf.CsrfViewMiddleware', 36 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 37 | 'django.contrib.messages.middleware.MessageMiddleware', 38 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 39 | ] 40 | 41 | ROOT_URLCONF = 'atlassian_jwt_auth.frameworks.django.tests.urls' 42 | 43 | TEMPLATES = [ 44 | { 45 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 46 | 'DIRS': [], 47 | 'APP_DIRS': True, 48 | 'OPTIONS': { 49 | 'context_processors': [ 50 | 'django.template.context_processors.debug', 51 | 'django.template.context_processors.request', 52 | 'django.contrib.auth.context_processors.auth', 53 | 'django.contrib.messages.context_processors.messages', 54 | ], 55 | }, 56 | }, 57 | ] 58 | 59 | # Database 60 | # https://docs.djangoproject.com/en/3.2/ref/settings/#databases 61 | 62 | DATABASES = { 63 | 'default': { 64 | 'ENGINE': 'django.db.backends.sqlite3', 65 | 'NAME': None, 66 | } 67 | } 68 | 69 | 70 | # Password validation 71 | # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators 72 | 73 | AUTH_PASSWORD_VALIDATORS = [ 74 | { 75 | 'NAME': 'django.contrib.auth.password_validation.' 76 | 'UserAttributeSimilarityValidator', 77 | }, 78 | { 79 | 'NAME': 'django.contrib.auth.password_validation.' 80 | 'MinimumLengthValidator', 81 | }, 82 | { 83 | 'NAME': 'django.contrib.auth.password_validation.' 84 | 'CommonPasswordValidator', 85 | }, 86 | { 87 | 'NAME': 'django.contrib.auth.password_validation.' 88 | 'NumericPasswordValidator', 89 | }, 90 | ] 91 | 92 | 93 | # Internationalization 94 | # https://docs.djangoproject.com/en/3.2/topics/i18n/ 95 | 96 | LANGUAGE_CODE = 'en-us' 97 | 98 | TIME_ZONE = 'UTC' 99 | 100 | USE_I18N = True 101 | 102 | USE_L10N = True 103 | 104 | USE_TZ = True 105 | 106 | 107 | # Static files (CSS, JavaScript, Images) 108 | # https://docs.djangoproject.com/en/3.2/howto/static-files/ 109 | 110 | STATIC_URL = '/static/' 111 | 112 | ASAP_VALID_AUDIENCE = 'server-app' 113 | ASAP_VALID_ISSUERS = ('client-app', 'whitelist') 114 | ASAP_PUBLICKEY_REPOSITORY = None 115 | 116 | # Default primary key field type 117 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field 118 | 119 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 120 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/frameworks/django/tests/test_django.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import django 4 | from django.test.testcases import SimpleTestCase 5 | from django.test.utils import override_settings, modify_settings 6 | 7 | try: 8 | from django.urls import reverse 9 | except ImportError: 10 | from django.core.urlresolvers import reverse 11 | 12 | from atlassian_jwt_auth.contrib.tests.utils import ( 13 | get_static_retriever_class, 14 | ) 15 | from atlassian_jwt_auth.tests import utils 16 | from atlassian_jwt_auth.tests.utils import ( 17 | create_token, 18 | RS256KeyTestMixin, 19 | ) 20 | 21 | 22 | class DjangoAsapMixin(object): 23 | 24 | @classmethod 25 | def setUpClass(cls): 26 | os.environ.setdefault( 27 | 'DJANGO_SETTINGS_MODULE', 28 | 'atlassian_jwt_auth.frameworks.django.tests.settings') 29 | 30 | django.setup() 31 | super(DjangoAsapMixin, cls).setUpClass() 32 | 33 | @classmethod 34 | def tearDownClass(cls): 35 | super(DjangoAsapMixin, cls).tearDownClass() 36 | del os.environ['DJANGO_SETTINGS_MODULE'] 37 | 38 | def setUp(self): 39 | super(DjangoAsapMixin, self).setUp() 40 | self._private_key_pem = self.get_new_private_key_in_pem_format() 41 | self._public_key_pem = utils.get_public_key_pem_for_private_key_pem( 42 | self._private_key_pem 43 | ) 44 | 45 | self.retriever = get_static_retriever_class({ 46 | 'client-app/key01': self._public_key_pem 47 | }) 48 | 49 | self.test_settings = { 50 | 'ASAP_KEY_RETRIEVER_CLASS': self.retriever 51 | } 52 | 53 | 54 | @modify_settings(MIDDLEWARE={ 55 | 'prepend': 'atlassian_jwt_auth.frameworks.django.asap_middleware', 56 | }) 57 | class TestAsapMiddleware(DjangoAsapMixin, RS256KeyTestMixin, SimpleTestCase): 58 | 59 | def check_response(self, 60 | view_name, 61 | response_content='', 62 | status_code=200, 63 | issuer='client-app', 64 | audience='server-app', 65 | key_id='client-app/key01', 66 | subject=None, 67 | private_key=None, 68 | token=None, 69 | authorization=None, 70 | retriever_key=None): 71 | if authorization is None: 72 | if token is None: 73 | if private_key is None: 74 | private_key = self._private_key_pem 75 | token = create_token(issuer=issuer, audience=audience, 76 | key_id=key_id, private_key=private_key, 77 | subject=subject) 78 | authorization = b'Bearer ' + token 79 | 80 | test_settings = self.test_settings.copy() 81 | if retriever_key is not None: 82 | retriever = get_static_retriever_class({ 83 | retriever_key: self._public_key_pem 84 | }) 85 | test_settings['ASAP_KEY_RETRIEVER_CLASS'] = retriever 86 | 87 | with override_settings(**test_settings): 88 | response = self.client.get(reverse(view_name), 89 | HTTP_AUTHORIZATION=authorization) 90 | 91 | self.assertContains(response, response_content, 92 | status_code=status_code) 93 | 94 | def test_request_with_valid_token_is_allowed(self): 95 | self.check_response('needed', 'one', 200) 96 | 97 | def test_request_with_valid_token_multiple_allowed_auds(self): 98 | audiences = ['server-app', 'another_one'] 99 | self.test_settings['ASAP_VALID_AUDIENCE'] = audiences 100 | for aud in audiences: 101 | self.check_response('needed', 'one', 200, audience=aud) 102 | 103 | def test_request_with_valid_token_multiple_allowed_auds_invalid_aud(self): 104 | audiences = ['server-app', 'another_one'] 105 | self.test_settings['ASAP_VALID_AUDIENCE'] = audiences 106 | self.check_response('needed', 'Unauthorized', 401, audience="invalid") 107 | 108 | def test_request_with_duplicate_jti_is_rejected_as_per_setting(self): 109 | self.test_settings['ASAP_CHECK_JTI_UNIQUENESS'] = True 110 | token = create_token( 111 | issuer='client-app', audience='server-app', 112 | key_id='client-app/key01', private_key=self._private_key_pem 113 | ) 114 | str_auth = 'Bearer ' + token.decode(encoding='iso-8859-1') 115 | self.check_response('needed', 'one', 200, authorization=str_auth) 116 | self.check_response('needed', 'duplicate jti', 401, 117 | authorization=str_auth) 118 | 119 | def _assert_request_with_duplicate_jti_is_accepted(self): 120 | token = create_token( 121 | issuer='client-app', audience='server-app', 122 | key_id='client-app/key01', private_key=self._private_key_pem 123 | ) 124 | str_auth = 'Bearer ' + token.decode(encoding='iso-8859-1') 125 | self.check_response('needed', 'one', 200, authorization=str_auth) 126 | self.check_response('needed', 'one', 200, authorization=str_auth) 127 | 128 | def test_request_with_duplicate_jti_is_accepted(self): 129 | self._assert_request_with_duplicate_jti_is_accepted() 130 | 131 | def test_request_with_duplicate_jti_is_accepted_as_per_setting(self): 132 | self.test_settings['ASAP_CHECK_JTI_UNIQUENESS'] = False 133 | self._assert_request_with_duplicate_jti_is_accepted() 134 | 135 | def test_request_with_string_headers_is_allowed(self): 136 | token = create_token( 137 | issuer='client-app', audience='server-app', 138 | key_id='client-app/key01', private_key=self._private_key_pem 139 | ) 140 | str_auth = 'Bearer ' + token.decode(encoding='iso-8859-1') 141 | self.check_response('needed', 'one', 200, authorization=str_auth) 142 | 143 | def test_request_with_invalid_audience_is_rejected(self): 144 | self.check_response('needed', 'Unauthorized', 401, 145 | audience='invalid') 146 | 147 | def test_request_with_invalid_token_is_rejected(self): 148 | self.check_response('needed', 'Unauthorized', 401, 149 | authorization='Bearer invalid') 150 | 151 | def test_request_without_token_is_rejected(self): 152 | with override_settings(**self.test_settings): 153 | response = self.client.get(reverse('needed')) 154 | 155 | self.assertContains(response, 'Unauthorized', 156 | status_code=401) 157 | 158 | def test_request_with_invalid_issuer_is_rejected(self): 159 | self.check_response('needed', 'Forbidden', 403, 160 | issuer='something-invalid', 161 | key_id='something-invalid/key01', 162 | retriever_key='something-invalid/key01') 163 | 164 | def test_request_non_whitelisted_decorated_issuer_is_rejected(self): 165 | self.check_response('needed', 'Forbidden', 403, 166 | issuer='unexpected', 167 | key_id='unexpected/key01', 168 | retriever_key='unexpected/key01') 169 | 170 | def test_request_non_decorated_issuer_is_rejected(self): 171 | self.check_response('restricted_issuer', 'Forbidden', 403) 172 | 173 | def test_request_decorated_issuer_is_allowed(self): 174 | self.check_response('restricted_issuer', 'three', 175 | issuer='whitelist', 176 | key_id='whitelist/key01', 177 | retriever_key='whitelist/key01') 178 | 179 | # TODO: modify JWTAuthSigner to allow non-issuer subjects and update the 180 | # decorated subject test cases 181 | def test_request_non_decorated_subject_is_rejected(self): 182 | self.check_response('restricted_subject', 'Forbidden', 403, 183 | issuer='whitelist', 184 | key_id='whitelist/key01', 185 | retriever_key='whitelist/key01') 186 | 187 | def test_request_using_settings_only_is_allowed(self): 188 | self.check_response('unneeded', 'two') 189 | 190 | def test_request_subject_does_not_need_to_match_issuer_from_settings(self): 191 | self.test_settings['ASAP_SUBJECT_SHOULD_MATCH_ISSUER'] = False 192 | self.check_response('needed', 'one', 200, subject='different_than_is') 193 | 194 | def test_request_subject_and_issue_not_matching(self): 195 | self.check_response( 196 | 'needed', 197 | 'Subject and Issuer do not match', 198 | 401, 199 | subject='different_than_is', 200 | ) 201 | 202 | 203 | class TestAsapDecorator(DjangoAsapMixin, RS256KeyTestMixin, SimpleTestCase): 204 | def test_request_with_valid_token_is_allowed(self): 205 | token = create_token( 206 | issuer='client-app', audience='server-app', 207 | key_id='client-app/key01', private_key=self._private_key_pem 208 | ) 209 | with override_settings(**self.test_settings): 210 | response = self.client.get(reverse('expected'), 211 | HTTP_AUTHORIZATION=b'Bearer ' + token) 212 | 213 | self.assertContains(response, 'Greatest Success!', status_code=200) 214 | 215 | def test_request_with_string_headers_is_allowed(self): 216 | token = create_token( 217 | issuer='client-app', audience='server-app', 218 | key_id='client-app/key01', private_key=self._private_key_pem 219 | ) 220 | str_token = token.decode(encoding='iso-8859-1') 221 | with override_settings(**self.test_settings): 222 | response = self.client.get(reverse('expected'), 223 | HTTP_AUTHORIZATION='Bearer ' + 224 | str_token) 225 | 226 | self.assertContains(response, 'Greatest Success!', status_code=200) 227 | 228 | def test_request_with_invalid_audience_is_rejected(self): 229 | token = create_token( 230 | issuer='client-app', audience='something-invalid', 231 | key_id='client-app/key01', private_key=self._private_key_pem 232 | ) 233 | with override_settings(**self.test_settings): 234 | response = self.client.get(reverse('expected'), 235 | HTTP_AUTHORIZATION=b'Bearer ' + token) 236 | 237 | self.assertContains(response, 'Unauthorized: Invalid token', 238 | status_code=401) 239 | 240 | def test_request_with_invalid_token_is_rejected(self): 241 | with override_settings(**self.test_settings): 242 | response = self.client.get( 243 | reverse('expected'), 244 | HTTP_AUTHORIZATION=b'Bearer notavalidtoken') 245 | 246 | self.assertContains(response, 'Unauthorized: Invalid token', 247 | status_code=401) 248 | 249 | def test_request_without_token_is_rejected(self): 250 | with override_settings(**self.test_settings): 251 | response = self.client.get(reverse('expected')) 252 | 253 | self.assertContains(response, 'Unauthorized', 254 | status_code=401) 255 | 256 | def test_request_with_invalid_issuer_is_rejected(self): 257 | retriever = get_static_retriever_class({ 258 | 'something-invalid/key01': self._public_key_pem 259 | }) 260 | token = create_token( 261 | issuer='something-invalid', audience='server-app', 262 | key_id='something-invalid/key01', private_key=self._private_key_pem 263 | ) 264 | with override_settings(ASAP_KEY_RETRIEVER_CLASS=retriever): 265 | response = self.client.get(reverse('expected'), 266 | HTTP_AUTHORIZATION=b'Bearer ' + token) 267 | 268 | self.assertContains(response, 'Forbidden: Invalid token issuer', 269 | status_code=403) 270 | 271 | def test_request_non_decorated_issuer_is_rejected(self): 272 | token = create_token( 273 | issuer='client-app', audience='server-app', 274 | key_id='client-app/key01', private_key=self._private_key_pem 275 | ) 276 | with override_settings(**self.test_settings): 277 | response = self.client.get(reverse('decorated'), 278 | HTTP_AUTHORIZATION=b'Bearer ' + token) 279 | 280 | self.assertContains(response, 'Forbidden: Invalid token issuer', 281 | status_code=403) 282 | 283 | def test_request_decorated_issuer_is_allowed(self): 284 | retriever = get_static_retriever_class({ 285 | 'whitelist/key01': self._public_key_pem 286 | }) 287 | token = create_token( 288 | issuer='whitelist', audience='server-app', 289 | key_id='whitelist/key01', private_key=self._private_key_pem 290 | ) 291 | with override_settings(ASAP_KEY_RETRIEVER_CLASS=retriever): 292 | response = self.client.get(reverse('decorated'), 293 | HTTP_AUTHORIZATION=b'Bearer ' + token) 294 | 295 | self.assertContains(response, 'Only the right issuer is allowed.') 296 | 297 | def test_request_using_settings_only_is_allowed(self): 298 | token = create_token( 299 | issuer='client-app', audience='server-app', 300 | key_id='client-app/key01', private_key=self._private_key_pem 301 | ) 302 | with override_settings(**self.test_settings): 303 | response = self.client.get(reverse('settings'), 304 | HTTP_AUTHORIZATION=b'Bearer ' + token) 305 | 306 | self.assertContains(response, 'Any settings issuer is allowed.') 307 | 308 | def test_request_subject_does_not_need_to_match_issuer(self): 309 | token = create_token( 310 | issuer='client-app', audience='server-app', 311 | key_id='client-app/key01', private_key=self._private_key_pem, 312 | subject='not-client-app', 313 | ) 314 | with override_settings(**self.test_settings): 315 | response = self.client.get( 316 | reverse('subject_does_not_need_to_match_issuer'), 317 | HTTP_AUTHORIZATION=b'Bearer ' + token) 318 | 319 | self.assertContains(response, 'Subject does not need to match issuer.') 320 | 321 | def test_request_subject_does_need_to_match_issuer_override_settings(self): 322 | """ tests that the with_asap decorator can override the 323 | ASAP_SUBJECT_SHOULD_MATCH_ISSUER setting. 324 | """ 325 | token = create_token( 326 | issuer='client-app', audience='server-app', 327 | key_id='client-app/key01', private_key=self._private_key_pem, 328 | subject='not-client-app', 329 | ) 330 | with override_settings(**dict( 331 | self.test_settings, ASAP_SUBJECT_SHOULD_MATCH_ISSUER=False)): 332 | response = self.client.get( 333 | reverse('subject_does_need_to_match_issuer'), 334 | HTTP_AUTHORIZATION=b'Bearer ' + token) 335 | self.assertContains( 336 | response, 337 | 'Unauthorized: Subject and Issuer do not match', 338 | status_code=401 339 | ) 340 | 341 | def test_request_subject_does_not_need_to_match_issuer_from_settings(self): 342 | token = create_token( 343 | issuer='client-app', audience='server-app', 344 | key_id='client-app/key01', private_key=self._private_key_pem, 345 | subject='not-client-app', 346 | ) 347 | with override_settings(**dict( 348 | self.test_settings, ASAP_SUBJECT_SHOULD_MATCH_ISSUER=False)): 349 | response = self.client.get( 350 | reverse('subject_does_not_need_to_match_issuer_from_settings'), 351 | HTTP_AUTHORIZATION=b'Bearer ' + token) 352 | 353 | self.assertContains( 354 | response, 'Subject does not need to match issuer (settings).') 355 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/frameworks/django/tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from atlassian_jwt_auth.frameworks.django.tests import views 4 | 5 | 6 | urlpatterns = [ 7 | path('asap/expected', views.expected_view, name='expected'), 8 | path(r'^asap/unexpected', views.unexpected_view, name='unexpected'), 9 | path('^asap/decorated', views.decorated_view, name='decorated'), 10 | path('asap/settings', views.settings_view, name='settings'), 11 | 12 | path('asap/subject_does_not_need_to_match_issuer', 13 | views.subject_does_not_need_to_match_issuer_view, 14 | name='subject_does_not_need_to_match_issuer'), 15 | path('asap/subject_does_need_to_match_issuer_view', 16 | views.subject_does_need_to_match_issuer_view, 17 | name='subject_does_need_to_match_issuer'), 18 | 19 | path('asap/subject_does_not_need_to_match_issuer_from_settings', 20 | views.subject_does_not_need_to_match_issuer_from_settings_view, 21 | name='subject_does_not_need_to_match_issuer_from_settings'), 22 | 23 | path('asap/needed', views.needed_view, name='needed'), 24 | path(r'asap/unneeded', views.unneeded_view, name='unneeded'), 25 | path(r'asap/restricted_issuer', views.restricted_issuer_view, 26 | name='restricted_issuer'), 27 | path('asap/restricted_subject', views.restricted_subject_view, 28 | name='restricted_subject'), 29 | ] 30 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/frameworks/django/tests/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | 3 | from atlassian_jwt_auth.frameworks.django import with_asap, restrict_asap 4 | from atlassian_jwt_auth.contrib.django.decorators import (requires_asap, 5 | validate_asap) 6 | 7 | 8 | @with_asap(issuers=['client-app']) 9 | def expected_view(request): 10 | return HttpResponse('Greatest Success!') 11 | 12 | 13 | @with_asap(issuers=['unexpected']) 14 | def unexpected_view(request): 15 | return HttpResponse('This should fail.') 16 | 17 | 18 | @with_asap(issuers=['whitelist']) 19 | def decorated_view(request): 20 | return HttpResponse('Only the right issuer is allowed.') 21 | 22 | 23 | @requires_asap() 24 | def settings_view(request): 25 | return HttpResponse('Any settings issuer is allowed.') 26 | 27 | 28 | @with_asap(subject_should_match_issuer=False) 29 | def subject_does_not_need_to_match_issuer_view(request): 30 | return HttpResponse('Subject does not need to match issuer.') 31 | 32 | 33 | @with_asap(subject_should_match_issuer=True) 34 | def subject_does_need_to_match_issuer_view(request): 35 | return HttpResponse('Subject does need to match issuer.') 36 | 37 | 38 | @with_asap() 39 | def subject_does_not_need_to_match_issuer_from_settings_view(request): 40 | return HttpResponse('Subject does not need to match issuer (settings).') 41 | 42 | 43 | @restrict_asap 44 | def needed_view(request): 45 | return HttpResponse('one') 46 | 47 | 48 | @restrict_asap(required=False) 49 | def unneeded_view(request): 50 | return HttpResponse('two') 51 | 52 | 53 | @restrict_asap(issuers=['whitelist']) 54 | def restricted_issuer_view(request): 55 | return HttpResponse('three') 56 | 57 | 58 | @validate_asap(subjects=['client-app']) 59 | def restricted_subject_view(request): 60 | return HttpResponse('four') 61 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/frameworks/flask/__init__.py: -------------------------------------------------------------------------------- 1 | from .decorators import with_asap # noqa 2 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/frameworks/flask/backend.py: -------------------------------------------------------------------------------- 1 | from flask import Response, current_app, g, request as current_req 2 | 3 | from ..common.backend import Backend 4 | 5 | 6 | class FlaskBackend(Backend): 7 | def get_authorization_header(self, request=None): 8 | if request is None: 9 | request = current_req 10 | 11 | return request.headers.get('AUTHORIZATION', '') 12 | 13 | def get_401_response(self, data=None, headers=None, request=None): 14 | if headers is None: 15 | headers = {} 16 | 17 | headers.update(self.default_headers_401) 18 | 19 | return Response(data, status=401, headers=headers) 20 | 21 | def get_403_response(self, data=None, headers=None, request=None): 22 | return Response(data, status=403, headers=headers) 23 | 24 | def set_asap_claims_for_request(self, request, claims): 25 | g.asap_claims = claims 26 | 27 | @property 28 | def settings(self): 29 | settings = {} 30 | 31 | settings.update(self.default_settings) 32 | 33 | for k in settings.keys(): 34 | value = current_app.config.get(k) 35 | if value is None: 36 | continue 37 | 38 | settings[k] = value 39 | 40 | return self._process_settings(settings) 41 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/frameworks/flask/decorators.py: -------------------------------------------------------------------------------- 1 | from ..common.decorators import _with_asap 2 | from .backend import FlaskBackend 3 | 4 | 5 | def with_asap(func=None, issuers=None, required=None, 6 | subject_should_match_issuer=None): 7 | """Decorator to allow endpoint-specific ASAP authentication. 8 | 9 | If authentication fails, a 401 or 403 response will be returned. Otherwise, 10 | the decorated function will be executed. 11 | 12 | The ASAP claimset will be set on g.asap_claims for further 13 | inspection later in the request lifecycle. 14 | 15 | :param list func: The view to decorate. 16 | :param list issuers: A list of valid token issuers that can access this 17 | endpoint. 18 | :param boolean required: Whether or not to require ASAP on this endpoint. 19 | :param boolean subject_should_match_issuer: Indicate whether the subject 20 | must match the issuer for a 21 | token to be considered valid. 22 | """ 23 | return _with_asap( 24 | func, FlaskBackend(), issuers, required, 25 | subject_should_match_issuer 26 | ) 27 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/frameworks/flask/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/asap-authentication-python/20eb8d99162033328bf66ba67d4097fb820e3744/atlassian_jwt_auth/frameworks/flask/tests/__init__.py -------------------------------------------------------------------------------- /atlassian_jwt_auth/frameworks/flask/tests/test_flask.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from flask import Flask 4 | 5 | from atlassian_jwt_auth.contrib.flask_app import requires_asap 6 | from atlassian_jwt_auth.contrib.tests.utils import get_static_retriever_class 7 | from atlassian_jwt_auth.frameworks.flask import with_asap 8 | from atlassian_jwt_auth.tests import utils 9 | from atlassian_jwt_auth.tests.utils import ( 10 | create_token, 11 | ) 12 | 13 | 14 | def get_app(): 15 | app = Flask(__name__) 16 | app.config.update({ 17 | 'ASAP_VALID_AUDIENCE': 'server-app', 18 | 'ASAP_VALID_ISSUERS': ('client-app',), 19 | 'ASAP_PUBLICKEY_REPOSITORY': None 20 | }) 21 | 22 | @app.route("/") 23 | @requires_asap 24 | def view(): 25 | return "OK" 26 | 27 | @app.route("/restricted-to-another-client/") 28 | @with_asap(issuers=['another-client']) 29 | def view_for_another_client_app(): 30 | return "OK" 31 | 32 | return app 33 | 34 | 35 | class FlaskTests(utils.RS256KeyTestMixin, unittest.TestCase): 36 | """ tests for the atlassian_jwt_auth.contrib.tests.flask """ 37 | 38 | def setUp(self): 39 | self._private_key_pem = self.get_new_private_key_in_pem_format() 40 | self._public_key_pem = utils.get_public_key_pem_for_private_key_pem( 41 | self._private_key_pem 42 | ) 43 | 44 | self.app = get_app() 45 | self.client = self.app.test_client() 46 | 47 | retriever = get_static_retriever_class({ 48 | 'client-app/key01': self._public_key_pem 49 | }) 50 | self.app.config['ASAP_KEY_RETRIEVER_CLASS'] = retriever 51 | 52 | def send_request(self, token, url='/'): 53 | """ returns the response of sending a request containing the given 54 | token sent in the Authorization header. 55 | """ 56 | 57 | # Note: We send the auth header as a string and not bytes here 58 | # due to how Werkzeug's Header code works. 59 | return self.client.get(url, headers={ 60 | 'Authorization': (b'Bearer ' + token).decode('iso-8859-1') 61 | }) 62 | 63 | def test_request_with_valid_token_is_allowed(self): 64 | token = create_token( 65 | 'client-app', 'server-app', 66 | 'client-app/key01', self._private_key_pem 67 | ) 68 | self.assertEqual(self.send_request(token).status_code, 200) 69 | 70 | def test_request_with_valid_token_multiple_allowed_auds(self): 71 | audiences = ['server-app', 'another_one'] 72 | self.app.config['ASAP_VALID_AUDIENCE'] = audiences 73 | for aud in audiences: 74 | token = create_token( 75 | 'client-app', aud, 76 | 'client-app/key01', self._private_key_pem 77 | ) 78 | self.assertEqual(self.send_request(token).status_code, 200) 79 | 80 | def test_request_with_valid_token_multiple_allowed_auds_invalid_aud(self): 81 | audiences = ['server-app', 'another_one'] 82 | self.app.config['ASAP_VALID_AUDIENCE'] = audiences 83 | token = create_token( 84 | 'client-app', "invalid", 85 | 'client-app/key01', self._private_key_pem 86 | ) 87 | self.assertEqual(self.send_request(token).status_code, 401) 88 | 89 | def test_request_with_duplicate_jti_is_rejected_as_per_setting(self): 90 | self.app.config['ASAP_CHECK_JTI_UNIQUENESS'] = True 91 | token = create_token( 92 | 'client-app', 'server-app', 93 | 'client-app/key01', self._private_key_pem 94 | ) 95 | self.assertEqual(self.send_request(token).status_code, 200) 96 | self.assertEqual(self.send_request(token).status_code, 401) 97 | 98 | def _assert_request_with_duplicate_jti_is_accepted(self): 99 | token = create_token( 100 | 'client-app', 'server-app', 101 | 'client-app/key01', self._private_key_pem 102 | ) 103 | self.assertEqual(self.send_request(token).status_code, 200) 104 | self.assertEqual(self.send_request(token).status_code, 200) 105 | 106 | def test_request_with_duplicate_jti_is_accepted(self): 107 | self._assert_request_with_duplicate_jti_is_accepted() 108 | 109 | def test_request_with_duplicate_jti_is_accepted_as_per_setting(self): 110 | self.app.config['ASAP_CHECK_JTI_UNIQUENESS'] = False 111 | self._assert_request_with_duplicate_jti_is_accepted() 112 | 113 | def test_request_with_invalid_audience_is_rejected(self): 114 | token = create_token( 115 | 'client-app', 'invalid-audience', 116 | 'client-app/key01', self._private_key_pem 117 | ) 118 | self.assertEqual(self.send_request(token).status_code, 401) 119 | 120 | def test_request_with_invalid_token_is_rejected(self): 121 | response = self.send_request(b'notavalidtoken') 122 | self.assertEqual(response.status_code, 401) 123 | 124 | def test_request_with_invalid_issuer_is_rejected(self): 125 | # Try with a different audience with a valid signature 126 | self.app.config['ASAP_KEY_RETRIEVER_CLASS'] = ( 127 | get_static_retriever_class({ 128 | 'another-client/key01': self._public_key_pem 129 | }) 130 | ) 131 | token = create_token( 132 | 'another-client', 'server-app', 133 | 'another-client/key01', self._private_key_pem 134 | ) 135 | self.assertEqual(self.send_request(token).status_code, 403) 136 | 137 | def test_decorated_request_with_invalid_issuer_is_rejected(self): 138 | # Try with a different audience with a valid signature 139 | token = create_token( 140 | 'client-app', 'server-app', 141 | 'client-app/key01', self._private_key_pem 142 | ) 143 | url = '/restricted-to-another-client/' 144 | self.assertEqual(self.send_request(token, url=url).status_code, 403) 145 | 146 | def test_request_subject_and_issue_not_matching(self): 147 | token = create_token( 148 | 'client-app', 'server-app', 149 | 'client-app/key01', self._private_key_pem, 150 | subject='different' 151 | ) 152 | self.assertEqual(self.send_request(token).status_code, 401) 153 | 154 | def test_request_subject_does_not_need_to_match_issuer_from_settings(self): 155 | self.app.config['ASAP_SUBJECT_SHOULD_MATCH_ISSUER'] = False 156 | token = create_token( 157 | 'client-app', 'server-app', 158 | 'client-app/key01', self._private_key_pem, 159 | subject='different' 160 | ) 161 | self.assertEqual(self.send_request(token).status_code, 200) 162 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/frameworks/wsgi/__init__.py: -------------------------------------------------------------------------------- 1 | from .middleware import ASAPMiddleware # noqa 2 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/frameworks/wsgi/backend.py: -------------------------------------------------------------------------------- 1 | from ..common.backend import Backend 2 | from ..common.utils import SettingsDict 3 | 4 | 5 | class WSGIBackend(Backend): 6 | def __init__(self, settings): 7 | self._settings = SettingsDict(settings) 8 | 9 | def get_authorization_header(self, request=None): 10 | if request is None: 11 | raise ValueError('No request available') 12 | 13 | return request.environ.get('HTTP_AUTHORIZATION', b'') 14 | 15 | def get_401_response(self, data=None, headers=None, request=None): 16 | if request is None: 17 | raise TypeError("request must have a value") 18 | 19 | if headers is None: 20 | headers = {} 21 | 22 | headers.update(self.default_headers_401) 23 | 24 | request.start_response('401 Unauthorized', list(headers.items()), None) 25 | return "" 26 | 27 | def get_403_response(self, data=None, headers=None, request=None): 28 | if request is None: 29 | raise TypeError("request must have a value") 30 | 31 | if headers is None: 32 | headers = {} 33 | 34 | request.start_response('403 Forbidden', list(headers.items()), None) 35 | return "" 36 | 37 | def set_asap_claims_for_request(self, request, claims): 38 | request.environ['ATL_ASAP_CLAIMS'] = claims 39 | 40 | @property 41 | def settings(self): 42 | settings = {} 43 | settings.update(self.default_settings) 44 | 45 | for k in settings.keys(): 46 | value = getattr(self._settings, k, None) 47 | if value is None: 48 | continue 49 | 50 | settings[k] = value 51 | 52 | return self._process_settings(settings) 53 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/frameworks/wsgi/middleware.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from ..common.asap import _process_asap_token 3 | from .backend import WSGIBackend 4 | 5 | Request = namedtuple('Request', ['environ', 'start_response']) 6 | 7 | 8 | class ASAPMiddleware(object): 9 | def __init__(self, handler, settings): 10 | self._next = handler 11 | self._backend = WSGIBackend(settings) 12 | self._verifier = self._backend.get_verifier() 13 | 14 | def __call__(self, environ, start_response): 15 | settings = self._backend.settings 16 | request = Request(environ, start_response) 17 | error_response = _process_asap_token( 18 | request, self._backend, settings, verifier=self._verifier 19 | ) 20 | if error_response is not None: 21 | return error_response 22 | 23 | return self._next(environ, start_response) 24 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/frameworks/wsgi/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/asap-authentication-python/20eb8d99162033328bf66ba67d4097fb820e3744/atlassian_jwt_auth/frameworks/wsgi/tests/__init__.py -------------------------------------------------------------------------------- /atlassian_jwt_auth/frameworks/wsgi/tests/test_wsgi.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from atlassian_jwt_auth.contrib.tests.utils import get_static_retriever_class 4 | from atlassian_jwt_auth.frameworks.wsgi.middleware import ASAPMiddleware 5 | from atlassian_jwt_auth.tests import utils 6 | from atlassian_jwt_auth.tests.utils import ( 7 | create_token, 8 | ) 9 | 10 | 11 | def app(environ, start_response): 12 | start_response('200 OK', [], None) 13 | return "OK" 14 | 15 | 16 | class WsgiTests(utils.RS256KeyTestMixin, unittest.TestCase): 17 | """ tests for the atlassian_jwt_auth.contrib.tests.flask """ 18 | 19 | def setUp(self): 20 | self._private_key_pem = self.get_new_private_key_in_pem_format() 21 | self._public_key_pem = utils.get_public_key_pem_for_private_key_pem( 22 | self._private_key_pem 23 | ) 24 | 25 | retriever = get_static_retriever_class({ 26 | 'client-app/key01': self._public_key_pem 27 | }) 28 | self.config = { 29 | 'ASAP_VALID_AUDIENCE': 'server-app', 30 | 'ASAP_VALID_ISSUERS': ('client-app',), 31 | 'ASAP_KEY_RETRIEVER_CLASS': retriever 32 | } 33 | 34 | def get_app_with_middleware(self, config): 35 | return ASAPMiddleware(app, config) 36 | 37 | def send_request(self, url='/', config=None, token=None, application=None): 38 | """ returns the response of sending a request containing the given 39 | token sent in the Authorization header. 40 | """ 41 | 42 | resp_info = {} 43 | 44 | def start_response(status, response_headers, exc_info=None): 45 | resp_info['status'] = status 46 | resp_info['headers'] = response_headers 47 | 48 | environ = {} 49 | if token: 50 | environ['HTTP_AUTHORIZATION'] = b'Bearer ' + token 51 | if application is None: 52 | application = self.get_app_with_middleware(config or self.config) 53 | return application(environ, start_response), resp_info, environ 54 | 55 | def test_request_with_valid_token_is_allowed(self): 56 | token = create_token( 57 | 'client-app', 'server-app', 58 | 'client-app/key01', self._private_key_pem 59 | ) 60 | body, resp_info, environ = self.send_request(token=token) 61 | self.assertEqual(resp_info['status'], '200 OK') 62 | self.assertIn('ATL_ASAP_CLAIMS', environ) 63 | 64 | def test_request_with_duplicate_jti_is_rejected_as_per_setting(self): 65 | self.config['ASAP_CHECK_JTI_UNIQUENESS'] = True 66 | token = create_token( 67 | 'client-app', 'server-app', 68 | 'client-app/key01', self._private_key_pem 69 | ) 70 | application = self.get_app_with_middleware(self.config) 71 | body, resp_info, environ = self.send_request( 72 | token=token, application=application) 73 | self.assertEqual(resp_info['status'], '200 OK') 74 | body, resp_info, environ = self.send_request( 75 | token=token, application=application) 76 | self.assertEqual(resp_info['status'], '401 Unauthorized') 77 | 78 | def _assert_request_with_duplicate_jti_is_accepted(self): 79 | token = create_token( 80 | 'client-app', 'server-app', 81 | 'client-app/key01', self._private_key_pem 82 | ) 83 | application = self.get_app_with_middleware(self.config) 84 | body, resp_info, environ = self.send_request( 85 | token=token, application=application) 86 | self.assertEqual(resp_info['status'], '200 OK') 87 | body, resp_info, environ = self.send_request( 88 | token=token, application=application) 89 | self.assertEqual(resp_info['status'], '200 OK') 90 | 91 | def test_request_with_duplicate_jti_is_accepted(self): 92 | self._assert_request_with_duplicate_jti_is_accepted() 93 | 94 | def test_request_with_duplicate_jti_is_accepted_as_per_setting(self): 95 | self.config['ASAP_CHECK_JTI_UNIQUENESS'] = False 96 | self._assert_request_with_duplicate_jti_is_accepted() 97 | 98 | def test_request_with_invalid_audience_is_rejected(self): 99 | token = create_token( 100 | 'client-app', 'invalid-audience', 101 | 'client-app/key01', self._private_key_pem 102 | ) 103 | body, resp_info, environ = self.send_request(token=token) 104 | self.assertEqual(resp_info['status'], '401 Unauthorized') 105 | self.assertNotIn('ATL_ASAP_CLAIMS', environ) 106 | 107 | def test_request_with_invalid_token_is_rejected(self): 108 | body, resp_info, environ = self.send_request(token=b'notavalidtoken') 109 | self.assertEqual(resp_info['status'], '401 Unauthorized') 110 | self.assertNotIn('ATL_ASAP_CLAIMS', environ) 111 | 112 | def test_request_subject_and_issue_not_matching(self): 113 | token = create_token( 114 | 'client-app', 'server-app', 115 | 'client-app/key01', self._private_key_pem, 116 | subject='different' 117 | ) 118 | body, resp_info, environ = self.send_request(token=token) 119 | self.assertEqual(resp_info['status'], '401 Unauthorized') 120 | self.assertNotIn('ATL_ASAP_CLAIMS', environ) 121 | 122 | def test_request_subject_does_not_need_to_match_issuer_from_settings(self): 123 | self.config['ASAP_SUBJECT_SHOULD_MATCH_ISSUER'] = False 124 | token = create_token( 125 | 'client-app', 'server-app', 126 | 'client-app/key01', self._private_key_pem, 127 | subject='different' 128 | ) 129 | body, resp_info, environ = self.send_request(token=token) 130 | self.assertEqual(resp_info['status'], '200 OK') 131 | self.assertIn('ATL_ASAP_CLAIMS', environ) 132 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/key.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import logging 3 | import os 4 | import re 5 | from urllib.parse import unquote_plus 6 | from email.message import EmailMessage 7 | 8 | import cachecontrol 9 | import cryptography.hazmat.backends 10 | import jwt 11 | import requests 12 | import requests.utils 13 | from cryptography.hazmat.primitives import serialization 14 | from requests.exceptions import RequestException, ConnectionError 15 | 16 | from atlassian_jwt_auth.exceptions import (KeyIdentifierException, 17 | PublicKeyRetrieverException, 18 | PrivateKeyRetrieverException) 19 | 20 | 21 | PEM_FILE_TYPE = 'application/x-pem-file' 22 | 23 | 24 | class KeyIdentifier(object): 25 | 26 | """ This class represents a key identifier """ 27 | 28 | def __init__(self, identifier): 29 | self.__key_id = validate_key_identifier(identifier) 30 | 31 | @property 32 | def key_id(self): 33 | return self.__key_id 34 | 35 | 36 | def validate_key_identifier(identifier): 37 | """ returns a validated key identifier. """ 38 | regex = re.compile(r'^[\w.\-\+/]*$') 39 | _error_msg = 'Invalid key identifier %s' % identifier 40 | if not identifier: 41 | raise KeyIdentifierException(_error_msg) 42 | if not regex.match(identifier): 43 | raise KeyIdentifierException(_error_msg) 44 | normalised = os.path.normpath(identifier) 45 | if normalised != identifier: 46 | raise KeyIdentifierException(_error_msg) 47 | if normalised.startswith('/'): 48 | raise KeyIdentifierException(_error_msg) 49 | if '..' in normalised: 50 | raise KeyIdentifierException(_error_msg) 51 | return identifier 52 | 53 | 54 | def _get_key_id_from_jwt_header(a_jwt): 55 | """ returns the key identifier from a jwt header. """ 56 | header = jwt.get_unverified_header(a_jwt) 57 | return KeyIdentifier(header['kid']) 58 | 59 | 60 | class BasePublicKeyRetriever(object): 61 | """ Base class for retrieving a public key. """ 62 | 63 | def retrieve(self, key_identifier, **kwargs): 64 | raise NotImplementedError() 65 | 66 | 67 | class HTTPSPublicKeyRetriever(BasePublicKeyRetriever): 68 | 69 | """ This class retrieves public key from a https location based upon the 70 | given key id. 71 | """ 72 | # Use a static requests session, reused/shared by all instances of 73 | # HTTPSPublicKeyRetriever: 74 | _class_session = None 75 | 76 | def __init__(self, base_url): 77 | if base_url is None or not base_url.startswith('https://'): 78 | raise PublicKeyRetrieverException( 79 | 'The base url must start with https://') 80 | if not base_url.endswith('/'): 81 | base_url += '/' 82 | self.base_url = base_url 83 | self._session = self._get_session() 84 | self._proxies = requests.utils.get_environ_proxies(self.base_url) 85 | 86 | def _get_session(self): 87 | if HTTPSPublicKeyRetriever._class_session is None: 88 | session = cachecontrol.CacheControl(requests.Session()) 89 | session.trust_env = False 90 | HTTPSPublicKeyRetriever._class_session = session 91 | return HTTPSPublicKeyRetriever._class_session 92 | 93 | def retrieve(self, key_identifier, **requests_kwargs): 94 | """ returns the public key for given key_identifier. """ 95 | if not isinstance(key_identifier, KeyIdentifier): 96 | key_identifier = KeyIdentifier(key_identifier) 97 | if self._proxies and 'proxies' not in requests_kwargs: 98 | requests_kwargs['proxies'] = self._proxies 99 | url = self.base_url + key_identifier.key_id 100 | try: 101 | return self._retrieve(url, requests_kwargs) 102 | except requests.RequestException as e: 103 | try: 104 | status_code = e.response.status_code 105 | except AttributeError: 106 | status_code = None 107 | raise PublicKeyRetrieverException(e, status_code=status_code) 108 | 109 | def _retrieve(self, url, requests_kwargs): 110 | resp = self._session.get(url, headers={'accept': PEM_FILE_TYPE}, 111 | **requests_kwargs) 112 | resp.raise_for_status() 113 | self._check_content_type(url, resp.headers['content-type']) 114 | return resp.text 115 | 116 | def _check_content_type(self, url, content_type): 117 | msg = EmailMessage() 118 | msg['content-type'] = content_type 119 | media_type = msg.get_content_type() 120 | 121 | if media_type.lower() != PEM_FILE_TYPE.lower(): 122 | raise PublicKeyRetrieverException( 123 | "Invalid content-type, '%s', for url '%s' ." % 124 | (content_type, url)) 125 | 126 | 127 | class HTTPSMultiRepositoryPublicKeyRetriever(BasePublicKeyRetriever): 128 | """ This class retrieves public key from the supplied https key 129 | repository locations based upon key ids. 130 | """ 131 | 132 | def __init__(self, key_repository_urls): 133 | if not isinstance(key_repository_urls, list): 134 | raise TypeError('keystore_urls must be a list of urls.') 135 | self._retrievers = self._create_retrievers(key_repository_urls) 136 | 137 | def _create_retrievers(self, key_repository_urls): 138 | return [HTTPSPublicKeyRetriever(url) for url 139 | in key_repository_urls] 140 | 141 | def handle_retrieval_exception(self, retriever, exception): 142 | """ Handles working with exceptions encountered during key 143 | retrieval. 144 | """ 145 | if isinstance(exception, PublicKeyRetrieverException): 146 | original_exception = getattr( 147 | exception, 'original_exception', None) 148 | if isinstance(original_exception, ConnectionError): 149 | return 150 | if exception.status_code is None or exception.status_code < 500: 151 | raise 152 | 153 | def retrieve(self, key_identifier, **requests_kwargs): 154 | for retriever in self._retrievers: 155 | try: 156 | return retriever.retrieve(key_identifier, **requests_kwargs) 157 | except (RequestException, PublicKeyRetrieverException) as e: 158 | self.handle_retrieval_exception(retriever, e) 159 | logger = logging.getLogger(__name__) 160 | logger.warning( 161 | 'Unable to retrieve public key from store', 162 | extra={'underlying_error': str(e), 163 | 'key repository': retriever.base_url}) 164 | raise PublicKeyRetrieverException( 165 | 'Cannot load key from key repositories') 166 | 167 | 168 | class BasePrivateKeyRetriever(object): 169 | """ This is the base private key retriever class. """ 170 | 171 | def load(self, issuer): 172 | """ returns the key identifier and private key pem found 173 | for the given issuer. 174 | """ 175 | raise NotImplementedError('Not implemented.') 176 | 177 | 178 | class DataUriPrivateKeyRetriever(BasePrivateKeyRetriever): 179 | """ This class can be used to retrieve the key identifier and 180 | private key from the supplied data uri. 181 | """ 182 | 183 | def __init__(self, data_uri): 184 | self._data_uri = data_uri 185 | 186 | def load(self, issuer): 187 | if not self._data_uri.startswith('data:application/pkcs8;kid='): 188 | raise PrivateKeyRetrieverException('Unrecognised data uri format.') 189 | splitted = self._data_uri.split(';') 190 | key_identifier = KeyIdentifier(unquote_plus( 191 | splitted[1][len('kid='):])) 192 | key_data = base64.b64decode(splitted[-1].split(',')[-1]) 193 | key = serialization.load_der_private_key( 194 | key_data, 195 | password=None, 196 | backend=cryptography.hazmat.backends.default_backend()) 197 | private_key_pem = key.private_bytes( 198 | encoding=serialization.Encoding.PEM, 199 | format=serialization.PrivateFormat.TraditionalOpenSSL, 200 | encryption_algorithm=serialization.NoEncryption() 201 | ) 202 | return key_identifier, private_key_pem.decode('utf-8') 203 | 204 | 205 | class StaticPrivateKeyRetriever(BasePrivateKeyRetriever): 206 | """ This class simply returns the key_identifier and private_key_pem 207 | initially provided to it in calls to load. 208 | """ 209 | 210 | def __init__(self, key_identifier, private_key_pem): 211 | if not isinstance(key_identifier, KeyIdentifier): 212 | key_identifier = KeyIdentifier(key_identifier) 213 | 214 | self.key_identifier = key_identifier 215 | self.private_key_pem = private_key_pem 216 | 217 | def load(self, issuer): 218 | return self.key_identifier, self.private_key_pem 219 | 220 | 221 | class FilePrivateKeyRetriever(BasePrivateKeyRetriever): 222 | """ This class can be used to retrieve the latest key identifier and 223 | private key for a given issuer found under its private key 224 | repository path. 225 | """ 226 | 227 | def __init__(self, private_key_repository_path): 228 | self.private_key_repository = FilePrivateKeyRepository( 229 | private_key_repository_path) 230 | 231 | def load(self, issuer): 232 | key_identifier = self._find_last_key_id(issuer) 233 | private_key_pem = self.private_key_repository.load_key(key_identifier) 234 | return key_identifier, private_key_pem 235 | 236 | def _find_last_key_id(self, issuer): 237 | key_identifiers = list( 238 | self.private_key_repository.find_valid_key_ids(issuer)) 239 | 240 | if key_identifiers: 241 | return key_identifiers[-1] 242 | else: 243 | raise IOError('Issuer has no valid keys: %s' % issuer) 244 | 245 | 246 | class FilePrivateKeyRepository(object): 247 | """ This class represents a file backed private key repository. """ 248 | 249 | def __init__(self, path): 250 | self.path = path 251 | 252 | def find_valid_key_ids(self, issuer): 253 | issuer_directory = os.path.join(self.path, issuer) 254 | for filename in sorted(os.listdir(issuer_directory)): 255 | if filename.endswith('.pem'): 256 | yield KeyIdentifier('%s/%s' % (issuer, filename)) 257 | 258 | def load_key(self, key_identifier): 259 | key_filename = os.path.join(self.path, key_identifier.key_id) 260 | with open(key_filename, 'rb') as f: 261 | return f.read().decode('utf-8') 262 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/signer.py: -------------------------------------------------------------------------------- 1 | import calendar 2 | import datetime 3 | import random 4 | 5 | import jwt 6 | from cryptography.hazmat.backends import default_backend 7 | from cryptography.hazmat.primitives import serialization 8 | 9 | from atlassian_jwt_auth import algorithms 10 | from atlassian_jwt_auth import key 11 | 12 | 13 | class JWTAuthSigner(object): 14 | 15 | def __init__(self, issuer, private_key_retriever, **kwargs): 16 | self.issuer = issuer 17 | self.private_key_retriever = private_key_retriever 18 | self.lifetime = kwargs.get('lifetime', datetime.timedelta(minutes=1)) 19 | self.algorithm = kwargs.get('algorithm', 'RS256') 20 | self.subject = kwargs.get('subject', None) 21 | self._private_keys_cache = dict() 22 | 23 | if self.algorithm not in set( 24 | algorithms.get_permitted_algorithm_names()): 25 | raise ValueError("Algorithm, '%s', is not permitted." % 26 | self.algorithm) 27 | if self.lifetime > datetime.timedelta(hours=1): 28 | raise ValueError("lifetime, '%s',exceeds the allowed 1 hour max" % 29 | (self.lifetime)) 30 | 31 | def _obtain_private_key(self, key_identifier, private_key_pem): 32 | """ returns a loaded instance of the given private key either from 33 | cache or from the given private_key_pem. 34 | """ 35 | priv_key = self._private_keys_cache.get(key_identifier.key_id, None) 36 | if priv_key is not None: 37 | return priv_key 38 | if not isinstance(private_key_pem, bytes): 39 | private_key_pem = private_key_pem.encode() 40 | priv_key = serialization.load_pem_private_key( 41 | private_key_pem, 42 | password=None, 43 | backend=default_backend() 44 | ) 45 | if len(self._private_keys_cache) > 10: 46 | self._private_keys_cache = dict() 47 | self._private_keys_cache[key_identifier.key_id] = priv_key 48 | return priv_key 49 | 50 | def _generate_claims(self, audience, **kwargs): 51 | """ returns a new dictionary of claims. """ 52 | now = self._now() 53 | claims = { 54 | 'iss': self.issuer, 55 | 'exp': now + self.lifetime, 56 | 'iat': now, 57 | 'aud': audience, 58 | 'jti': '%s:%s' % ( 59 | now.strftime('%s'), random.SystemRandom().getrandbits(32)), 60 | 'nbf': now, 61 | 'sub': self.subject or self.issuer, 62 | } 63 | claims.update(kwargs.get('additional_claims', {})) 64 | return claims 65 | 66 | def _now(self): 67 | return datetime.datetime.now(datetime.timezone.utc) 68 | 69 | def generate_jwt(self, audience, **kwargs): 70 | """ returns a new signed jwt for use. """ 71 | key_identifier, private_key_pem = self.private_key_retriever.load( 72 | self.issuer) 73 | private_key = self._obtain_private_key( 74 | key_identifier, private_key_pem) 75 | token = jwt.encode( 76 | self._generate_claims(audience, **kwargs), 77 | key=private_key, 78 | algorithm=self.algorithm, 79 | headers={'kid': key_identifier.key_id}) 80 | if isinstance(token, str): 81 | token = token.encode('utf-8') 82 | return token 83 | 84 | 85 | class TokenReusingJWTAuthSigner(JWTAuthSigner): 86 | 87 | def __init__(self, issuer, private_key_retriever, **kwargs): 88 | super(TokenReusingJWTAuthSigner, self).__init__( 89 | issuer, private_key_retriever, **kwargs) 90 | self.reuse_threshold = kwargs.get('reuse_jwt_threshold', 0.95) 91 | 92 | def get_cached_token(self, audience, **kwargs): 93 | """ returns the cached token. If there is no matching cached token 94 | then None is returned. 95 | """ 96 | return getattr(self, '_previous_token', None) 97 | 98 | def set_cached_token(self, value): 99 | """ sets the cached token.""" 100 | self._previous_token = value 101 | 102 | def can_reuse_token(self, existing_token, claims): 103 | """ returns True if the provided existing token can be reused 104 | for the claims provided. 105 | """ 106 | if existing_token is None: 107 | return False 108 | existing_claims = jwt.decode( 109 | existing_token, options={'verify_signature': False}) 110 | existing_lifetime = (int(existing_claims['exp']) - 111 | int(existing_claims['iat'])) 112 | this_lifetime = (claims['exp'] - claims['iat']).total_seconds() 113 | if existing_lifetime != this_lifetime: 114 | return False 115 | about_to_expire = int(existing_claims['iat']) + ( 116 | self.reuse_threshold * existing_lifetime) 117 | if calendar.timegm(self._now().utctimetuple()) > about_to_expire: 118 | return False 119 | if set(claims.keys()) != set(existing_claims.keys()): 120 | return False 121 | for dict_key, val in claims.items(): 122 | if dict_key in ['exp', 'iat', 'jti', 'nbf']: 123 | continue 124 | if existing_claims[dict_key] != val: 125 | return False 126 | return True 127 | 128 | def generate_jwt(self, audience, **kwargs): 129 | existing_token = self.get_cached_token(audience, **kwargs) 130 | claims = self._generate_claims(audience, **kwargs) 131 | if existing_token and self.can_reuse_token(existing_token, claims): 132 | return existing_token 133 | token = super(TokenReusingJWTAuthSigner, self).generate_jwt( 134 | audience, **kwargs) 135 | self.set_cached_token(token) 136 | return token 137 | 138 | 139 | def _create_signer(issuer, private_key_retriever, **kwargs): 140 | signer_cls = JWTAuthSigner 141 | if kwargs.get('reuse_jwts', None): 142 | signer_cls = TokenReusingJWTAuthSigner 143 | return signer_cls(issuer, private_key_retriever, **kwargs) 144 | 145 | 146 | def create_signer(issuer, key_identifier, private_key_pem, **kwargs): 147 | private_key_retriever = key.StaticPrivateKeyRetriever( 148 | key_identifier, private_key_pem) 149 | return _create_signer(issuer, private_key_retriever, **kwargs) 150 | 151 | 152 | def create_signer_from_file_private_key_repository( 153 | issuer, private_key_repository, **kwargs): 154 | private_key_retriever = key.FilePrivateKeyRetriever(private_key_repository) 155 | return _create_signer(issuer, private_key_retriever, **kwargs) 156 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian/asap-authentication-python/20eb8d99162033328bf66ba67d4097fb820e3744/atlassian_jwt_auth/tests/__init__.py -------------------------------------------------------------------------------- /atlassian_jwt_auth/tests/test_key.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import atlassian_jwt_auth 4 | 5 | 6 | class TestKeyModule(unittest.TestCase): 7 | 8 | """ tests for the key module. """ 9 | 10 | def test_key_identifier_with_invalid_keys(self): 11 | """ test that invalid key identifiers are not permitted. """ 12 | keys = ['../aha', '/a', r'\c:a', 'lk2j34/#$', 'a../../a', 'a/;a', 13 | ' ', ' / ', ' /', 14 | u'dir/some\0thing', 'a/#a', 'a/a?x', 'a/a;', 15 | ] 16 | for key in keys: 17 | with self.assertRaises(ValueError): 18 | atlassian_jwt_auth.KeyIdentifier(identifier=key) 19 | 20 | def test_key_identifier_with_valid_keys(self): 21 | """ test that valid keys work as expected. """ 22 | for key in ['oa.oo/a', 'oo.sasdf.asdf/yes', 'oo/o']: 23 | key_id = atlassian_jwt_auth.KeyIdentifier(identifier=key) 24 | self.assertEqual(key_id.key_id, key) 25 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/tests/test_private_key_provider.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import unittest 3 | 4 | from cryptography.hazmat.backends import default_backend 5 | from cryptography.hazmat.primitives.serialization import load_pem_private_key 6 | from cryptography.hazmat.primitives import serialization 7 | 8 | from atlassian_jwt_auth.signer import JWTAuthSigner 9 | from atlassian_jwt_auth.tests import utils 10 | from atlassian_jwt_auth.key import DataUriPrivateKeyRetriever 11 | 12 | 13 | def convert_key_pem_format_to_der_format(private_key_pem): 14 | private_key = load_pem_private_key(private_key_pem, 15 | password=None, 16 | backend=default_backend()) 17 | return private_key.private_bytes( 18 | encoding=serialization.Encoding.DER, 19 | format=serialization.PrivateFormat.PKCS8, 20 | encryption_algorithm=serialization.NoEncryption() 21 | ) 22 | 23 | 24 | class BaseDataUriPrivateKeyRetrieverTest(object): 25 | """ tests for the DataUriPrivateKeyRetriever class. """ 26 | 27 | def setUp(self): 28 | self._private_key_pem = self.get_new_private_key_in_pem_format() 29 | self._public_key_pem = utils.get_public_key_pem_for_private_key_pem( 30 | self._private_key_pem) 31 | self._private_key_der = convert_key_pem_format_to_der_format( 32 | self._private_key_pem) 33 | 34 | def get_example_data_uri(self, private_key_der): 35 | return ('data:application/pkcs8;kid=example%2Feg;base64,' + 36 | base64.b64encode(private_key_der).decode('utf-8')) 37 | 38 | def test_load_data_uri(self): 39 | """ tests that a valid data uri is correctly loaded. """ 40 | expected_kid = 'example/eg' 41 | data_uri = self.get_example_data_uri(self._private_key_der) 42 | provider = DataUriPrivateKeyRetriever(data_uri) 43 | kid, private_key_pem = provider.load('example') 44 | self.assertEqual(kid.key_id, expected_kid) 45 | self.assertEqual(private_key_pem, 46 | self._private_key_pem.decode('utf-8')) 47 | 48 | def test_load_data_uri_can_be_used_with_a_signer(self): 49 | """ tests that the data uri private key retriever can be used with a 50 | signer to generate a jwt. 51 | """ 52 | data_uri = self.get_example_data_uri(self._private_key_der) 53 | provider = DataUriPrivateKeyRetriever(data_uri) 54 | jwt_auth_signer = JWTAuthSigner( 55 | 'issuer', provider, algorithm=self.algorithm) 56 | jwt_auth_signer.generate_jwt('aud') 57 | 58 | 59 | class DataUriPrivateKeyRetrieverRS256Test(BaseDataUriPrivateKeyRetrieverTest, 60 | utils.RS256KeyTestMixin, 61 | unittest.TestCase): 62 | pass 63 | 64 | 65 | class DataUriPrivateKeyRetrieverES256Test(BaseDataUriPrivateKeyRetrieverTest, 66 | utils.ES256KeyTestMixin, 67 | unittest.TestCase): 68 | pass 69 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/tests/test_public_key_provider.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import unittest 4 | from unittest import mock 5 | 6 | import httptest 7 | import requests 8 | 9 | from atlassian_jwt_auth.key import ( 10 | HTTPSPublicKeyRetriever, 11 | HTTPSMultiRepositoryPublicKeyRetriever, 12 | PEM_FILE_TYPE, 13 | ) 14 | from atlassian_jwt_auth.tests import utils 15 | 16 | 17 | def get_expected_and_os_proxies_dict(proxy_location): 18 | """ returns expected proxy & environmental 19 | proxy dictionary based upon the provided proxy location. 20 | """ 21 | expected_proxies = { 22 | 'http': proxy_location, 23 | 'https': proxy_location, 24 | } 25 | os_proxy_dict = { 26 | 'HTTP_PROXY': proxy_location, 27 | 'HTTPS_PROXY': proxy_location 28 | } 29 | return expected_proxies, os_proxy_dict 30 | 31 | 32 | class BaseHTTPSPublicKeyRetrieverTest(object): 33 | """ tests for the HTTPSPublicKeyRetriever class. """ 34 | 35 | def create_retriever(self, url): 36 | """ returns a public key retriever created using the given url. """ 37 | return HTTPSPublicKeyRetriever(url) 38 | 39 | def setUp(self): 40 | self._private_key_pem = self.get_new_private_key_in_pem_format() 41 | self._public_key_pem = utils.get_public_key_pem_for_private_key_pem( 42 | self._private_key_pem) 43 | self.base_url = 'https://example.com' 44 | 45 | def test_https_public_key_retriever_does_not_support_http_url(self): 46 | """ tests that HTTPSPublicKeyRetriever does not support http:// 47 | base urls. 48 | """ 49 | with self.assertRaises(ValueError): 50 | self.create_retriever('http://example.com') 51 | 52 | def test_https_public_key_retriever_does_not_support_none_url(self): 53 | """ tests that HTTPSPublicKeyRetriever does not support None 54 | base urls. 55 | """ 56 | with self.assertRaises(ValueError): 57 | self.create_retriever(None) 58 | 59 | def test_https_public_key_retriever_session_uses_env_proxy(self): 60 | """ tests that the underlying session makes use of environmental 61 | proxy configured. 62 | """ 63 | proxy_location = 'https://example.proxy' 64 | expected_proxies, proxy_dict = get_expected_and_os_proxies_dict( 65 | proxy_location) 66 | with mock.patch.dict(os.environ, proxy_dict, clear=True): 67 | retriever = self.create_retriever(self.base_url) 68 | key_retrievers = [retriever] 69 | if isinstance(retriever, HTTPSMultiRepositoryPublicKeyRetriever): 70 | key_retrievers = retriever._retrievers 71 | for key_retriever in key_retrievers: 72 | self.assertEqual(key_retriever._proxies, expected_proxies) 73 | 74 | def test_https_public_key_retriever_supports_https_url(self): 75 | """ tests that HTTPSPublicKeyRetriever supports https:// 76 | base urls. 77 | """ 78 | self.create_retriever(self.base_url) 79 | 80 | @mock.patch.object(requests.Session, 'get') 81 | def test_retrieve(self, mock_get_method): 82 | """ tests that the retrieve method works expected. """ 83 | _setup_mock_response_for_retriever( 84 | mock_get_method, self._public_key_pem) 85 | retriever = self.create_retriever(self.base_url) 86 | self.assertEqual( 87 | retriever.retrieve('example/eg'), 88 | self._public_key_pem) 89 | 90 | @mock.patch.object(requests.Session, 'get') 91 | def test_retrieve_with_proxy(self, mock_get_method): 92 | """ tests that the retrieve method works as expected when a proxy 93 | should be used. 94 | """ 95 | proxy_location = 'https://example.proxy' 96 | key_id = 'example/eg' 97 | expected_proxies, proxy_dict = get_expected_and_os_proxies_dict( 98 | proxy_location) 99 | _setup_mock_response_for_retriever( 100 | mock_get_method, self._public_key_pem) 101 | with mock.patch.dict(os.environ, proxy_dict, clear=True): 102 | retriever = self.create_retriever(self.base_url) 103 | retriever.retrieve(key_id) 104 | mock_get_method.assert_called_once_with( 105 | '%s/%s' % (self.base_url, key_id), 106 | headers={'accept': PEM_FILE_TYPE}, 107 | proxies=expected_proxies 108 | ) 109 | 110 | @mock.patch.object(requests.Session, 'get') 111 | def test_retrieve_with_proxy_explicitly_set(self, mock_get_method): 112 | """ tests that the retrieve method works as expected when a proxy 113 | should be used and has been explicitly provided. 114 | """ 115 | proxy_location = 'https://example.proxy' 116 | explicit_proxy_location = 'https://explicit.proxy' 117 | key_id = 'example/eg' 118 | _, proxy_dict = get_expected_and_os_proxies_dict(proxy_location) 119 | expected_proxies, _ = get_expected_and_os_proxies_dict( 120 | explicit_proxy_location) 121 | _setup_mock_response_for_retriever( 122 | mock_get_method, self._public_key_pem) 123 | with mock.patch.dict(os.environ, proxy_dict, clear=True): 124 | retriever = self.create_retriever(self.base_url) 125 | retriever.retrieve(key_id, proxies=expected_proxies) 126 | mock_get_method.assert_called_once_with( 127 | '%s/%s' % (self.base_url, key_id), 128 | headers={'accept': PEM_FILE_TYPE}, 129 | proxies=expected_proxies 130 | ) 131 | 132 | @mock.patch.object(requests.Session, 'get') 133 | def test_retrieve_with_charset_in_content_type_h(self, mock_get_method): 134 | """ tests that the retrieve method works expected when there is 135 | a charset in the response content-type header. 136 | """ 137 | headers = {'content-type': 'application/x-pem-file;charset=UTF-8'} 138 | _setup_mock_response_for_retriever( 139 | mock_get_method, self._public_key_pem, headers) 140 | retriever = self.create_retriever(self.base_url) 141 | self.assertEqual( 142 | retriever.retrieve('example/eg'), 143 | self._public_key_pem) 144 | 145 | @mock.patch.object(requests.Session, 'get') 146 | def test_retrieve_fails_with_different_content_type(self, mock_get_method): 147 | """ tests that the retrieve method fails when the response is for a 148 | media type that is not supported. 149 | """ 150 | headers = {'content-type': 'different/not-supported'} 151 | _setup_mock_response_for_retriever( 152 | mock_get_method, self._public_key_pem, headers) 153 | retriever = self.create_retriever(self.base_url) 154 | with self.assertRaises(ValueError): 155 | retriever.retrieve('example/eg') 156 | 157 | @mock.patch.object(requests.Session, 'get', 158 | side_effect=requests.exceptions.HTTPError( 159 | mock.Mock(response=mock.Mock(status_code=403)), 160 | 'forbidden')) 161 | def test_retrieve_fails_with_forbidden_error(self, mock_get_method): 162 | """ tests that the retrieve method fails when the response is an 163 | 403 forbidden error. 164 | """ 165 | _setup_mock_response_for_retriever( 166 | mock_get_method, self._public_key_pem) 167 | retriever = self.create_retriever(self.base_url) 168 | with self.assertRaises(ValueError): 169 | retriever.retrieve('example/eg') 170 | 171 | 172 | class CachedHTTPPublicKeyRetrieverTest(utils.ES256KeyTestMixin, 173 | unittest.TestCase): 174 | 175 | class HTTPPublicKeyRetriever(HTTPSPublicKeyRetriever): 176 | """A subclass of HTTPSPublicKeyRetriever that allows us to use plain 177 | HTTP during testing so we don't have to run an actual SSL server. 178 | """ 179 | 180 | def __init__(self, base_url): 181 | # pretend to the super class that this is an HTTPS url 182 | super(CachedHTTPPublicKeyRetrieverTest.HTTPPublicKeyRetriever, 183 | self).__init__( 184 | re.sub(r'^http', 'https', base_url, flags=re.IGNORECASE)) 185 | self.base_url = base_url 186 | 187 | def setUp(self): 188 | super(CachedHTTPPublicKeyRetrieverTest, self).setUp() 189 | self._private_key_pem = self.get_new_private_key_in_pem_format() 190 | self._public_key_pem = utils.get_public_key_pem_for_private_key_pem( 191 | self._private_key_pem) 192 | 193 | def test_http_caching(self): 194 | """Asserts that our use of requests properly caches keys between 195 | invocations across different `HTTPSPublicKeyRetriever` instances. 196 | """ 197 | def wsgi(environ, start_response): 198 | print(environ['PATH_INFO']) 199 | start_response('200 OK', [ 200 | ('content-type', 'application/x-pem-file;charset=UTF-8'), 201 | ('Cache-Control', 'public,max-age=300,stale-while-revalidate=' 202 | '300,stale-if-error=300'), 203 | ('Last-Modified', 'Sun, 18 Jan 1970 18:14:21 GMT')]) 204 | return [self._public_key_pem] 205 | 206 | with httptest.testserver(wsgi) as server: 207 | 208 | retriever = self.HTTPPublicKeyRetriever(server.url()) 209 | retriever.retrieve('example/eg') 210 | 211 | retriever = self.HTTPPublicKeyRetriever(server.url()) 212 | retriever.retrieve('example/eg') 213 | 214 | self.assertEqual(1, len(server.log()), 215 | msg='HTTP caching should suppress second GET') 216 | 217 | 218 | class BaseHTTPSMultiRepositoryPublicKeyRetrieverTest( 219 | BaseHTTPSPublicKeyRetrieverTest): 220 | """ tests for the HTTPSMultiRepositoryPublicKeyRetriever class. """ 221 | 222 | def create_retriever(self, url): 223 | """ returns a public key retriever created using the given url. """ 224 | return HTTPSMultiRepositoryPublicKeyRetriever([url]) 225 | 226 | def setUp(self): 227 | self._private_key_pem = self.get_new_private_key_in_pem_format() 228 | self._public_key_pem = utils.get_public_key_pem_for_private_key_pem( 229 | self._private_key_pem) 230 | self.keystore_urls = ['https://example.com', 'https://example.ly'] 231 | self.base_url = self.keystore_urls[0] 232 | 233 | def test_https_multi_public_key_retriever_does_not_support_strings(self): 234 | """ tests that HTTPSMultiRepositoryPublicKeyRetriever does not 235 | support a string key repository url. 236 | """ 237 | with self.assertRaises(TypeError): 238 | HTTPSMultiRepositoryPublicKeyRetriever('https://example.com') 239 | 240 | @mock.patch.object(requests.Session, 'get') 241 | def test_retrieve(self, mock_get_method): 242 | """ tests that the retrieve method works expected. """ 243 | _setup_mock_response_for_retriever( 244 | mock_get_method, self._public_key_pem) 245 | retriever = HTTPSMultiRepositoryPublicKeyRetriever(self.keystore_urls) 246 | self.assertEqual( 247 | retriever.retrieve('example/eg'), 248 | self._public_key_pem) 249 | 250 | @mock.patch.object(requests.Session, 'get') 251 | def test_retrieve_with_500_error(self, mock_get_method): 252 | """ tests that the retrieve method works as expected 253 | when the first key repository returns a server error response. 254 | """ 255 | retriever = HTTPSMultiRepositoryPublicKeyRetriever(self.keystore_urls) 256 | _setup_mock_response_for_retriever( 257 | mock_get_method, self._public_key_pem) 258 | valid_response = mock_get_method.return_value 259 | del mock_get_method.return_value 260 | server_exception = requests.exceptions.HTTPError( 261 | response=mock.Mock(status_code=500)) 262 | mock_get_method.side_effect = [server_exception, valid_response] 263 | self.assertEqual( 264 | retriever.retrieve('example/eg'), 265 | self._public_key_pem) 266 | 267 | @mock.patch.object(requests.Session, 'get') 268 | def test_retrieve_with_connection_error(self, mock_get_method): 269 | """ tests that the retrieve method works as expected 270 | when the first key repository encounters a connection error. 271 | """ 272 | retriever = HTTPSMultiRepositoryPublicKeyRetriever(self.keystore_urls) 273 | _setup_mock_response_for_retriever( 274 | mock_get_method, self._public_key_pem) 275 | valid_response = mock_get_method.return_value 276 | del mock_get_method.return_value 277 | connection_exception = requests.exceptions.ConnectionError( 278 | response=mock.Mock(status_code=None)) 279 | mock_get_method.side_effect = [connection_exception, valid_response] 280 | self.assertEqual( 281 | retriever.retrieve('example/eg'), 282 | self._public_key_pem) 283 | 284 | 285 | def _setup_mock_response_for_retriever( 286 | mock_method, public_key_pem, headers=None): 287 | """ returns a setup mock response for use with a https public key 288 | retriever. 289 | """ 290 | if headers is None: 291 | headers = {'content-type': 'application/x-pem-file'} 292 | mock_response = mock.Mock() 293 | mock_response.headers = headers 294 | mock_response.text = public_key_pem 295 | mock_method.return_value = mock_response 296 | return mock_method 297 | 298 | 299 | class HTTPSPublicKeyRetrieverRS256Test(BaseHTTPSPublicKeyRetrieverTest, 300 | utils.RS256KeyTestMixin, 301 | unittest.TestCase): 302 | pass 303 | 304 | 305 | class HTTPSPublicKeyRetrieverES256Test(BaseHTTPSPublicKeyRetrieverTest, 306 | utils.ES256KeyTestMixin, 307 | unittest.TestCase): 308 | pass 309 | 310 | 311 | class HTTPSMultiRepositoryPublicKeyRetrieverRS256Test( 312 | BaseHTTPSMultiRepositoryPublicKeyRetrieverTest, 313 | utils.RS256KeyTestMixin, 314 | unittest.TestCase): 315 | pass 316 | 317 | 318 | class HTTPSMultiRepositoryPublicKeyRetrieverES256Test( 319 | BaseHTTPSMultiRepositoryPublicKeyRetrieverTest, 320 | utils.ES256KeyTestMixin, 321 | unittest.TestCase): 322 | pass 323 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/tests/test_signer.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import unittest 3 | from unittest import mock 4 | 5 | from cryptography.hazmat.primitives import serialization 6 | 7 | import atlassian_jwt_auth 8 | from atlassian_jwt_auth.tests import utils 9 | 10 | 11 | class BaseJWTAuthSignerTest(object): 12 | 13 | """ tests for the JWTAuthSigner class. """ 14 | 15 | def setUp(self): 16 | self._private_key_pem = self.get_new_private_key_in_pem_format() 17 | 18 | def test__generate_claims(self): 19 | """ tests that _generate_claims works as expected. """ 20 | expected_now = datetime.datetime(year=2001, day=1, month=1) 21 | expected_audience = 'example_aud' 22 | expected_iss = 'eg' 23 | expected_key_id = 'eg/ex' 24 | jwt_auth_signer = atlassian_jwt_auth.create_signer( 25 | expected_iss, 26 | expected_key_id, 27 | self._private_key_pem) 28 | jwt_auth_signer._now = lambda: expected_now 29 | for additional_claims in [{}, {'extra': 'thing'}]: 30 | expected_claims = { 31 | 'iss': expected_iss, 32 | 'exp': expected_now + datetime.timedelta(minutes=1), 33 | 'iat': expected_now, 34 | 'aud': expected_audience, 35 | 'nbf': expected_now, 36 | 'sub': expected_iss, 37 | } 38 | expected_claims.update(additional_claims) 39 | claims = jwt_auth_signer._generate_claims( 40 | expected_audience, 41 | additional_claims=additional_claims) 42 | self.assertIsNotNone(claims['jti']) 43 | del claims['jti'] 44 | self.assertEqual(claims, expected_claims) 45 | 46 | def test_jti_changes(self): 47 | """ tests that the jti of a claim changes. """ 48 | expected_now = datetime.datetime(year=2001, day=1, month=1) 49 | aud = 'aud' 50 | jwt_auth_signer = utils.get_example_jwt_auth_signer( 51 | algorithm=self.algorithm, private_key_pem=self._private_key_pem) 52 | jwt_auth_signer._now = lambda: expected_now 53 | first = jwt_auth_signer._generate_claims(aud)['jti'] 54 | second = jwt_auth_signer._generate_claims(aud)['jti'] 55 | self.assertNotEqual(first, second) 56 | self.assertTrue(str(expected_now.strftime('%s')) in first) 57 | self.assertTrue(str(expected_now.strftime('%s')) in second) 58 | 59 | @mock.patch('jwt.encode') 60 | def test_generate_jwt(self, m_jwt_encode): 61 | """ tests that generate_jwt works as expected. """ 62 | expected_aud = 'aud_x' 63 | expected_claims = {'eg': 'ex'} 64 | expected_key_id = 'key_id' 65 | expected_issuer = 'a_issuer' 66 | jwt_auth_signer = atlassian_jwt_auth.create_signer( 67 | expected_issuer, 68 | expected_key_id, 69 | private_key_pem=self._private_key_pem, 70 | algorithm=self.algorithm, 71 | ) 72 | jwt_auth_signer._generate_claims = lambda aud: expected_claims 73 | jwt_auth_signer.generate_jwt(expected_aud) 74 | m_jwt_encode.assert_called_with( 75 | expected_claims, 76 | key=mock.ANY, 77 | algorithm=self.algorithm, 78 | headers={'kid': expected_key_id}) 79 | for name, args, kwargs in m_jwt_encode.mock_calls: 80 | if not kwargs: 81 | self.assertEqual(args[0], 'utf-8') 82 | continue 83 | call_private_key = kwargs['key'].private_bytes( 84 | encoding=serialization.Encoding.PEM, 85 | format=serialization.PrivateFormat.TraditionalOpenSSL, 86 | encryption_algorithm=serialization.NoEncryption() 87 | ) 88 | self.assertEqual(call_private_key, self._private_key_pem) 89 | 90 | 91 | class JWTAuthSignerRS256Test( 92 | BaseJWTAuthSignerTest, 93 | utils.RS256KeyTestMixin, 94 | unittest.TestCase): 95 | pass 96 | 97 | 98 | class JWTAuthSignerES256Test( 99 | BaseJWTAuthSignerTest, 100 | utils.ES256KeyTestMixin, 101 | unittest.TestCase): 102 | pass 103 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/tests/test_signer_private_key_repo.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import tempfile 4 | import unittest 5 | 6 | 7 | import atlassian_jwt_auth 8 | from atlassian_jwt_auth import key 9 | from atlassian_jwt_auth.tests import utils 10 | 11 | 12 | class BaseJWTAuthSignerWithFilePrivateKeyRetrieverTest(object): 13 | 14 | """ tests for the JWTAuthSigner using the FilePrivateKeyRetriever. """ 15 | 16 | def setUp(self): 17 | self.test_dir = tempfile.mkdtemp(prefix='atlassian-jwt-p-tests') 18 | self.key_dir = os.path.join(self.test_dir, 'jwtprivatekeys') 19 | for dir in ['invalid-issuer', 'issuer-with-many-keys', 20 | 'valid-issuer']: 21 | os.makedirs(os.path.join(self.key_dir, dir)) 22 | self._private_key_pem = self.get_new_private_key_in_pem_format() 23 | for file_loc in [ 24 | 'invalid-issuer/key-tests-pem.new', 25 | 'issuer-with-many-keys/key1.pem.new', 26 | 'issuer-with-many-keys/key2.pem', 27 | 'issuer-with-many-keys/key3.pem', 28 | 'issuer-with-many-keys/key4.pem.new', 29 | 'valid-issuer/key-for-tests.pem' 30 | ]: 31 | file_location = os.path.join(self.key_dir, file_loc) 32 | with open(file_location, 'wb') as f: 33 | f.write(self._private_key_pem) 34 | 35 | def tearDown(self): 36 | if self.test_dir: 37 | shutil.rmtree(self.test_dir) 38 | 39 | def create_signer_for_issuer(self, issuer): 40 | return \ 41 | atlassian_jwt_auth.create_signer_from_file_private_key_repository( 42 | issuer, self.key_dir, algorithm=self.algorithm) 43 | 44 | def test_succeeds_if_issuer_has_one_valid_key(self): 45 | signer = self.create_signer_for_issuer('valid-issuer') 46 | token = signer.generate_jwt('audience') 47 | self.assertIsNotNone(token) 48 | 49 | def test_picks_last_valid_key_id(self): 50 | signer = self.create_signer_for_issuer('issuer-with-many-keys') 51 | token = signer.generate_jwt('audience') 52 | key_identifier = key._get_key_id_from_jwt_header(token) 53 | expected_key_id = 'issuer-with-many-keys/key3.pem' 54 | self.assertEqual(key_identifier.key_id, expected_key_id) 55 | 56 | def test_fails_if_issuer_has_no_valid_keys(self): 57 | signer = self.create_signer_for_issuer('invalid-issuer') 58 | with self.assertRaisesRegex(IOError, 'Issuer has no valid keys'): 59 | signer.generate_jwt('audience') 60 | 61 | def test_fails_if_issuer_does_not_exist(self): 62 | signer = self.create_signer_for_issuer('this-does-not-exist') 63 | with self.assertRaisesRegex(OSError, 'No such file or directory'): 64 | signer.generate_jwt('audience') 65 | 66 | 67 | class JWTAuthSignerWithFilePrivateKeyRetrieverRS256Test( 68 | BaseJWTAuthSignerWithFilePrivateKeyRetrieverTest, 69 | utils.RS256KeyTestMixin, 70 | unittest.TestCase): 71 | pass 72 | 73 | 74 | class JWTAuthSignerWithFilePrivateKeyRetrieverES256Test( 75 | BaseJWTAuthSignerWithFilePrivateKeyRetrieverTest, 76 | utils.ES256KeyTestMixin, 77 | unittest.TestCase): 78 | pass 79 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/tests/test_verifier.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import unittest 3 | from unittest import mock 4 | 5 | import jwt 6 | import jwt.algorithms 7 | import jwt.exceptions 8 | 9 | import atlassian_jwt_auth 10 | import atlassian_jwt_auth.exceptions 11 | import atlassian_jwt_auth.key 12 | import atlassian_jwt_auth.signer 13 | from atlassian_jwt_auth.tests import utils 14 | 15 | 16 | class NoneAlgorithmJwtAuthSigner(atlassian_jwt_auth.signer.JWTAuthSigner): 17 | """ A JWTAuthSigner that generates JWTs using the none algorithm 18 | and supports specifying arbitrary alg jwt header values. 19 | """ 20 | 21 | def generate_jwt(self, audience, **kwargs): 22 | alg_header = kwargs.get('alg_header', 'none') 23 | key_identifier, private_key_pem = self.private_key_retriever.load( 24 | self.issuer) 25 | return jwt.encode(self._generate_claims(audience, **kwargs), 26 | algorithm=None, 27 | key=None, 28 | headers={'kid': key_identifier.key_id, 29 | 'alg': alg_header}) 30 | 31 | 32 | class BaseJWTAuthVerifierTest(object): 33 | 34 | """ tests for the JWTAuthVerifier class. """ 35 | 36 | def setUp(self): 37 | self._private_key_pem = self.get_new_private_key_in_pem_format() 38 | self._public_key_pem = utils.get_public_key_pem_for_private_key_pem( 39 | self._private_key_pem) 40 | self._example_aud = 'aud_x' 41 | self._example_issuer = 'egissuer' 42 | self._example_key_id = '%s/a' % self._example_issuer 43 | self._jwt_auth_signer = atlassian_jwt_auth.create_signer( 44 | self._example_issuer, 45 | self._example_key_id, 46 | self._private_key_pem.decode(), 47 | algorithm=self.algorithm 48 | ) 49 | 50 | def _setup_mock_public_key_retriever(self, pub_key_pem): 51 | m_public_key_ret = mock.Mock() 52 | m_public_key_ret.retrieve.return_value = pub_key_pem.decode() 53 | return m_public_key_ret 54 | 55 | def _setup_jwt_auth_verifier(self, pub_key_pem, **kwargs): 56 | m_public_key_ret = self._setup_mock_public_key_retriever(pub_key_pem) 57 | return atlassian_jwt_auth.JWTAuthVerifier(m_public_key_ret, **kwargs) 58 | 59 | def test_verify_jwt_with_valid_jwt(self): 60 | """ test that verify_jwt verifies a valid jwt. """ 61 | verifier = self._setup_jwt_auth_verifier(self._public_key_pem) 62 | signed_jwt = self._jwt_auth_signer.generate_jwt( 63 | self._example_aud) 64 | v_claims = verifier.verify_jwt(signed_jwt, self._example_aud) 65 | self.assertIsNotNone(v_claims) 66 | self.assertEqual(v_claims['aud'], self._example_aud) 67 | self.assertEqual(v_claims['iss'], self._example_issuer) 68 | 69 | def test_verify_jwt_with_none_algorithm(self): 70 | """ tests that verify_jwt does not accept jwt that use the none 71 | algorithm. 72 | """ 73 | verifier = self._setup_jwt_auth_verifier(self._public_key_pem) 74 | private_key_ret = atlassian_jwt_auth.key.StaticPrivateKeyRetriever( 75 | self._example_key_id, self._private_key_pem.decode()) 76 | jwt_signer = NoneAlgorithmJwtAuthSigner( 77 | issuer=self._example_issuer, 78 | private_key_retriever=private_key_ret, 79 | ) 80 | for algorithm in ['none', 'None', 'nOne', 'nonE', 'NONE']: 81 | if algorithm != 'none': 82 | jwt.register_algorithm( 83 | algorithm, jwt.algorithms.NoneAlgorithm()) 84 | jwt_token = jwt_signer.generate_jwt( 85 | self._example_aud, alg_header=algorithm) 86 | if algorithm != 'none': 87 | jwt.unregister_algorithm(algorithm) 88 | jwt_headers = jwt.get_unverified_header(jwt_token) 89 | self.assertEqual(jwt_headers['alg'], algorithm) 90 | with self.assertRaises(jwt.exceptions.InvalidAlgorithmError): 91 | verifier.verify_jwt(jwt_token, self._example_aud) 92 | 93 | def test_verify_jwt_with_key_identifier_not_starting_with_issuer(self): 94 | """ tests that verify_jwt rejects a jwt if the key identifier does 95 | not start with the claimed issuer. 96 | """ 97 | verifier = self._setup_jwt_auth_verifier(self._public_key_pem) 98 | signer = atlassian_jwt_auth.create_signer( 99 | 'issuer', 'issuerx', self._private_key_pem.decode(), 100 | algorithm=self.algorithm, 101 | ) 102 | a_jwt = signer.generate_jwt(self._example_aud) 103 | with self.assertRaisesRegex(ValueError, 'Issuer does not own'): 104 | verifier.verify_jwt(a_jwt, self._example_aud) 105 | 106 | @mock.patch('atlassian_jwt_auth.verifier.jwt.decode') 107 | def test_verify_jwt_with_non_matching_sub_and_iss(self, m_j_decode): 108 | """ tests that verify_jwt rejects a jwt if the claims 109 | contains a subject which does not match the issuer. 110 | """ 111 | expected_msg = 'Issuer does not match the subject' 112 | m_j_decode.return_value = { 113 | 'iss': self._example_issuer, 114 | 'sub': self._example_issuer[::-1] 115 | } 116 | a_jwt = self._jwt_auth_signer.generate_jwt(self._example_aud) 117 | verifier = self._setup_jwt_auth_verifier(self._public_key_pem) 118 | for exception in [ 119 | ValueError, 120 | atlassian_jwt_auth.exceptions.SubjectDoesNotMatchIssuerException, 121 | ]: 122 | with self.assertRaisesRegex(exception, expected_msg): 123 | verifier.verify_jwt(a_jwt, self._example_aud) 124 | 125 | @mock.patch('atlassian_jwt_auth.verifier.jwt.decode') 126 | def test_verify_jwt_with_jwt_lasting_gt_max_time(self, m_j_decode): 127 | """ tests that verify_jwt rejects a jwt if the claims 128 | period of validity is greater than the allowed maximum. 129 | """ 130 | expected_msg = 'exceeds the maximum' 131 | claims = self._jwt_auth_signer._generate_claims(self._example_aud) 132 | claims['iat'] = claims['exp'] - datetime.timedelta(minutes=61) 133 | for key in ['iat', 'exp']: 134 | claims[key] = claims[key].strftime('%s') 135 | m_j_decode.return_value = claims 136 | a_jwt = self._jwt_auth_signer.generate_jwt(self._example_aud) 137 | verifier = self._setup_jwt_auth_verifier(self._public_key_pem) 138 | with self.assertRaisesRegex(ValueError, expected_msg): 139 | verifier.verify_jwt(a_jwt, self._example_aud) 140 | 141 | def test_verify_jwt_with_jwt_with_already_seen_jti(self): 142 | """ tests that verify_jwt rejects a jwt if the jti 143 | has already been seen. 144 | """ 145 | verifier = self._setup_jwt_auth_verifier( 146 | self._public_key_pem, check_jti_uniqueness=True) 147 | a_jwt = self._jwt_auth_signer.generate_jwt( 148 | self._example_aud) 149 | self.assertIsNotNone(verifier.verify_jwt( 150 | a_jwt, 151 | self._example_aud)) 152 | for exception in [ 153 | ValueError, 154 | atlassian_jwt_auth.exceptions.JtiUniquenessException]: 155 | with self.assertRaisesRegex(exception, 'has already been used'): 156 | verifier.verify_jwt(a_jwt, self._example_aud) 157 | 158 | def assert_jwt_accepted_more_than_once(self, verifier, a_jwt): 159 | """ asserts that the given jwt is accepted more than once. """ 160 | for i in range(0, 3): 161 | self.assertIsNotNone( 162 | verifier.verify_jwt(a_jwt, self._example_aud)) 163 | 164 | def test_verify_jwt_with_already_seen_jti_with_uniqueness_disabled(self): 165 | """ tests that verify_jwt accepts a jwt if the jti 166 | has already been seen and the verifier has been set 167 | to not check the uniqueness of jti. 168 | """ 169 | verifier = self._setup_jwt_auth_verifier( 170 | self._public_key_pem, check_jti_uniqueness=False) 171 | a_jwt = self._jwt_auth_signer.generate_jwt(self._example_aud) 172 | self.assert_jwt_accepted_more_than_once(verifier, a_jwt) 173 | 174 | def test_verify_jwt_with_already_seen_jti_default(self): 175 | """ tests that verify_jwt by default accepts a jwt if the jti 176 | has already been seen. 177 | """ 178 | verifier = self._setup_jwt_auth_verifier( 179 | self._public_key_pem) 180 | a_jwt = self._jwt_auth_signer.generate_jwt(self._example_aud) 181 | self.assert_jwt_accepted_more_than_once(verifier, a_jwt) 182 | 183 | def test_verify_jwt_subject_should_match_issuer(self): 184 | verifier = self._setup_jwt_auth_verifier( 185 | self._public_key_pem, subject_should_match_issuer=True) 186 | a_jwt = self._jwt_auth_signer.generate_jwt( 187 | self._example_aud, 188 | additional_claims={'sub': 'not-' + self._example_issuer}) 189 | with self.assertRaisesRegex(ValueError, 190 | 'Issuer does not match the subject.'): 191 | verifier.verify_jwt(a_jwt, self._example_aud) 192 | 193 | def test_verify_jwt_subject_does_not_need_to_match_issuer(self): 194 | verifier = self._setup_jwt_auth_verifier( 195 | self._public_key_pem, subject_should_match_issuer=False) 196 | a_jwt = self._jwt_auth_signer.generate_jwt( 197 | self._example_aud, 198 | additional_claims={'sub': 'not-' + self._example_issuer}) 199 | self.assertIsNotNone(verifier.verify_jwt(a_jwt, self._example_aud)) 200 | 201 | @mock.patch('atlassian_jwt_auth.verifier.jwt.decode') 202 | def test_verify_jwt_with_missing_aud_claim(self, m_j_decode): 203 | """ tests that verify_jwt rejects jwt that do not have an aud 204 | claim. 205 | """ 206 | expected_msg = ('Claims validity, the aud claim must be provided and ' 207 | 'cannot be empty.') 208 | claims = self._jwt_auth_signer._generate_claims(self._example_aud) 209 | del claims['aud'] 210 | m_j_decode.return_value = claims 211 | a_jwt = self._jwt_auth_signer.generate_jwt(self._example_aud) 212 | verifier = self._setup_jwt_auth_verifier(self._public_key_pem) 213 | with self.assertRaisesRegex(KeyError, expected_msg): 214 | verifier.verify_jwt(a_jwt, self._example_aud) 215 | 216 | def test_verify_jwt_with_none_aud(self): 217 | """ tests that verify_jwt rejects jwt that have a None aud claim. """ 218 | verifier = self._setup_jwt_auth_verifier(self._public_key_pem) 219 | a_jwt = self._jwt_auth_signer.generate_jwt( 220 | self._example_aud, 221 | additional_claims={'aud': None}) 222 | exceptions = (jwt.exceptions.InvalidAudienceError, 223 | jwt.exceptions.InvalidTokenError) 224 | with self.assertRaises(exceptions) as cm: 225 | verifier.verify_jwt(a_jwt, self._example_aud) 226 | if not isinstance(cm.exception, jwt.exceptions.InvalidAudienceError): 227 | self.assertIn('aud', str(cm.exception)) 228 | 229 | def test_verify_jwt_with_non_matching_aud(self): 230 | """ tests that verify_jwt rejects a jwt if the aud claim does not 231 | match the given & expected audience. 232 | """ 233 | verifier = self._setup_jwt_auth_verifier(self._public_key_pem) 234 | a_jwt = self._jwt_auth_signer.generate_jwt( 235 | self._example_aud, 236 | additional_claims={'aud': self._example_aud + '-different'}) 237 | with self.assertRaises(jwt.exceptions.InvalidAudienceError): 238 | verifier.verify_jwt(a_jwt, self._example_aud) 239 | 240 | 241 | class JWTAuthVerifierRS256Test( 242 | BaseJWTAuthVerifierTest, 243 | utils.RS256KeyTestMixin, 244 | unittest.TestCase): 245 | pass 246 | 247 | 248 | class JWTAuthVerifierES256Test( 249 | BaseJWTAuthVerifierTest, 250 | utils.ES256KeyTestMixin, 251 | unittest.TestCase): 252 | pass 253 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/tests/utils.py: -------------------------------------------------------------------------------- 1 | from cryptography.hazmat.backends import default_backend 2 | from cryptography.hazmat.primitives.asymmetric import rsa 3 | from cryptography.hazmat.primitives.asymmetric import ec 4 | from cryptography.hazmat.primitives import serialization 5 | 6 | import atlassian_jwt_auth 7 | 8 | 9 | def get_new_rsa_private_key_in_pem_format(): 10 | """ returns a new rsa key in pem format. """ 11 | private_key = rsa.generate_private_key( 12 | key_size=2048, backend=default_backend(), public_exponent=65537) 13 | return private_key.private_bytes( 14 | encoding=serialization.Encoding.PEM, 15 | format=serialization.PrivateFormat.TraditionalOpenSSL, 16 | encryption_algorithm=serialization.NoEncryption() 17 | ) 18 | 19 | 20 | def get_public_key_pem_for_private_key_pem(private_key_pem): 21 | private_key = serialization.load_pem_private_key( 22 | private_key_pem, 23 | password=None, 24 | backend=default_backend() 25 | ) 26 | public_key = private_key.public_key() 27 | return public_key.public_bytes( 28 | encoding=serialization.Encoding.PEM, 29 | format=serialization.PublicFormat.SubjectPublicKeyInfo 30 | ) 31 | 32 | 33 | def get_example_jwt_auth_signer(**kwargs): 34 | """ returns an example jwt_auth_signer instance. """ 35 | issuer = kwargs.get('issuer', 'egissuer') 36 | key_id = kwargs.get('key_id', '%s/a' % issuer) 37 | key = kwargs.get( 38 | 'private_key_pem', get_new_rsa_private_key_in_pem_format()) 39 | algorithm = kwargs.get('algorithm', 'RS256') 40 | return atlassian_jwt_auth.create_signer( 41 | issuer, key_id, key, algorithm=algorithm) 42 | 43 | 44 | def create_token(issuer, audience, key_id, private_key, subject=None): 45 | """" returns a token based upon the supplied parameters. """ 46 | signer = atlassian_jwt_auth.create_signer( 47 | issuer, key_id, private_key, subject=subject) 48 | return signer.generate_jwt(audience) 49 | 50 | 51 | class BaseJWTAlgorithmTestMixin(object): 52 | 53 | """ A mixin class to make testing different support for different 54 | jwt algorithms easier. 55 | """ 56 | 57 | def get_new_private_key_in_pem_format(self): 58 | """ returns a new private key in pem format. """ 59 | raise NotImplementedError("not implemented.") 60 | 61 | 62 | class RS256KeyTestMixin(object): 63 | 64 | """ Private rs256 test mixin. """ 65 | 66 | @property 67 | def algorithm(self): 68 | return 'RS256' 69 | 70 | def get_new_private_key_in_pem_format(self): 71 | return get_new_rsa_private_key_in_pem_format() 72 | 73 | 74 | class ES256KeyTestMixin(object): 75 | 76 | """ Private es256 test mixin. """ 77 | 78 | @property 79 | def algorithm(self): 80 | return 'ES256' 81 | 82 | def get_new_private_key_in_pem_format(self): 83 | private_key = ec.generate_private_key( 84 | ec.SECP256R1(), default_backend()) 85 | return private_key.private_bytes( 86 | encoding=serialization.Encoding.PEM, 87 | format=serialization.PrivateFormat.TraditionalOpenSSL, 88 | encryption_algorithm=serialization.NoEncryption() 89 | ) 90 | -------------------------------------------------------------------------------- /atlassian_jwt_auth/verifier.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | from functools import lru_cache 3 | 4 | import jwt 5 | import jwt.api_jwt 6 | from cryptography.hazmat.primitives.asymmetric.ec import ( 7 | EllipticCurvePublicKey 8 | ) 9 | from cryptography.hazmat.primitives.asymmetric.rsa import ( 10 | RSAPublicKey 11 | ) 12 | from jwt.exceptions import InvalidAlgorithmError 13 | 14 | from atlassian_jwt_auth import algorithms 15 | from atlassian_jwt_auth import key 16 | from atlassian_jwt_auth import exceptions 17 | 18 | 19 | @lru_cache(maxsize=10) 20 | def _load_public_key(algorithms, public_key, algorithm): 21 | """ Returns a public key object instance given the public key and 22 | algorithm. 23 | 24 | This has been extracted out of JWTAuthVerifier to avoid possible memory 25 | leaks via retained instance references. 26 | """ 27 | if isinstance(public_key, (RSAPublicKey, EllipticCurvePublicKey)): 28 | return public_key 29 | if algorithm not in algorithms: 30 | raise InvalidAlgorithmError( 31 | 'The specified alg value is not allowed') 32 | py_jws = jwt.api_jws.PyJWS(algorithms=algorithms) 33 | alg_obj = py_jws._algorithms[algorithm] 34 | return alg_obj.prepare_key(public_key) 35 | 36 | 37 | class JWTAuthVerifier(object): 38 | 39 | """ This class can be used to verify a JWT. """ 40 | 41 | def __init__(self, public_key_retriever, **kwargs): 42 | self.public_key_retriever = public_key_retriever 43 | self.algorithms = algorithms.get_permitted_algorithm_names() 44 | self._seen_jti = OrderedDict() 45 | self._subject_should_match_issuer = kwargs.get( 46 | 'subject_should_match_issuer', True) 47 | self._check_jti_uniqueness = kwargs.get( 48 | 'check_jti_uniqueness', False) 49 | 50 | def verify_jwt(self, a_jwt, audience, leeway=0, **requests_kwargs): 51 | """Verify if the token is correct 52 | 53 | Returns: 54 | dict: the claims of the given jwt if verification is successful. 55 | 56 | Raises: 57 | ValueError: if verification failed. 58 | """ 59 | key_identifier = key._get_key_id_from_jwt_header(a_jwt) 60 | public_key = self._retrieve_pub_key(key_identifier, requests_kwargs) 61 | 62 | alg = jwt.get_unverified_header(a_jwt).get('alg', None) 63 | public_key_obj = self._load_public_key(public_key, alg) 64 | return self._decode_jwt( 65 | a_jwt, key_identifier, public_key_obj, 66 | audience=audience, leeway=leeway) 67 | 68 | def _retrieve_pub_key(self, key_identifier, requests_kwargs): 69 | return self.public_key_retriever.retrieve( 70 | key_identifier, **requests_kwargs) 71 | 72 | def _load_public_key(self, public_key, algorithm): 73 | """ Returns a public key object instance given the public key and 74 | algorithm. 75 | """ 76 | return _load_public_key(tuple(self.algorithms), public_key, algorithm) 77 | 78 | def _decode_jwt(self, a_jwt, key_identifier, jwt_key, 79 | audience=None, leeway=0): 80 | """Decode JWT and check if it's valid""" 81 | options = { 82 | 'verify_signature': True, 83 | 'require': ['exp', 'iat'], 84 | 'require_exp': True, 85 | 'require_iat': True, 86 | } 87 | 88 | claims = jwt.decode( 89 | a_jwt, 90 | key=jwt_key, 91 | algorithms=self.algorithms, 92 | options=options, 93 | audience=audience, 94 | leeway=leeway) 95 | 96 | if (not key_identifier.key_id.startswith('%s/' % claims['iss']) and 97 | key_identifier.key_id != claims['iss']): 98 | raise ValueError('Issuer does not own the supplied public key') 99 | 100 | if self._subject_should_match_issuer and ( 101 | claims.get('sub') and claims['iss'] != claims['sub']): 102 | raise exceptions.SubjectDoesNotMatchIssuerException( 103 | 'Issuer does not match the subject.') 104 | 105 | _aud = claims.get('aud', None) 106 | if _aud is None: 107 | _msg = ("Claims validity, the aud claim must be provided and " 108 | "cannot be empty.") 109 | raise KeyError(_msg) 110 | _exp = int(claims['exp']) 111 | _iat = int(claims['iat']) 112 | if _exp - _iat > 3600: 113 | _msg = ("Claims validity, '%s', exceeds the maximum 1 hour." % 114 | (_exp - _iat)) 115 | raise ValueError(_msg) 116 | _jti = claims['jti'] 117 | if self._check_jti_uniqueness: 118 | self._check_jti(_jti) 119 | return claims 120 | 121 | def _check_jti(self, jti): 122 | """Checks that the given jti has not been already been used.""" 123 | if jti in self._seen_jti: 124 | raise exceptions.JtiUniquenessException( 125 | "The jti, '%s', has already been used." % jti) 126 | self._seen_jti[jti] = None 127 | while len(self._seen_jti) > 1000: 128 | self._seen_jti.popitem(last=False) 129 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyJWT>=2.4.0,<3.0.0 2 | PyJWT[crypto]>=2.4.0,<3.0.0 3 | requests>=2.8.1,<3.0.0 4 | CacheControl 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = atlassian-jwt-auth 3 | summary = Python implementation of the Atlassian Service to Service Authentication specification. 4 | author = Atlassian 5 | author_email = dblack@atlassian.com 6 | url = https://github.com/atlassian/asap-authentication-python 7 | project_urls = 8 | Bug Tracker = https://github.com/atlassian/asap-authentication-python/issues 9 | Source Code = https://github.com/atlassian/asap-authentication-python 10 | description_file = 11 | README.rst 12 | license = MIT 13 | classifier = 14 | Development Status :: 4 - Beta 15 | Environment :: Console 16 | Intended Audience :: Developers 17 | Operating System :: OS Independent 18 | Programming Language :: Python 19 | Programming Language :: Python :: 3 20 | Programming Language :: Python :: 3.9 21 | Programming Language :: Python :: 3.10 22 | Programming Language :: Python :: 3.11 23 | Programming Language :: Python :: 3.12 24 | Programming Language :: Python :: 3.13 25 | License :: OSI Approved :: MIT License 26 | 27 | [files] 28 | packages = 29 | atlassian_jwt_auth 30 | 31 | [bdist_wheel] 32 | universal=1 33 | 34 | [tool:pytest] 35 | addopts = -s -v -x 36 | 37 | [aliases] 38 | test=pytest 39 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup 3 | 4 | 5 | setup( 6 | setup_requires=['pbr<7.0.0'], 7 | pbr=True, 8 | platforms=['any'], 9 | zip_safe=False, 10 | ) 11 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | pytest<9.0.0 2 | flask>=2.0.3,<4.0.0 3 | Django>=3.2.9,<5.0.0 4 | atlassian-httptest==1.0.0 5 | aiohttp==3.11.6 6 | asynctest==0.13.0 7 | --------------------------------------------------------------------------------