├── .github └── workflows │ └── test.yaml ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── Pipfile ├── Pipfile.lock ├── README.md ├── oidc_auth ├── __init__.py ├── authentication.py ├── settings.py ├── test.py └── util.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── settings.py ├── test_authentication.py └── test_util.py └── tox.ini /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | on: 3 | pull_request: 4 | push: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: ['3.7', '3.8', '3.9', '3.10'] 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | python -m pip install tox tox-gh-actions 23 | - name: Test with tox 24 | run: tox 25 | -------------------------------------------------------------------------------- /.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 | MANIFEST 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | 47 | # Translations 48 | *.mo 49 | *.pot 50 | 51 | # Django stuff: 52 | *.log 53 | 54 | # Sphinx documentation 55 | docs/_build/ 56 | 57 | # PyBuilder 58 | target/ 59 | 60 | # IDEA 61 | .idea/ 62 | 63 | # VSCode 64 | .vscode/ 65 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | dist: focal 3 | python: 4 | - 3.7 5 | - 3.8 6 | - 3.9 7 | - 3.10 8 | install: 9 | - pip install -U tox tox-travis pip virtualenv 10 | script: 11 | - tox 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 |

Changelog

2 |

1.0.0

3 | Replace the deprecated `jwkest` library with the maintained `authlib` library. Note that this is not backwards compatible, but this might not be immediately obvious. You have to adjust your settings, i.e. `OIDC_AUDIENCES` is deprecated and replaced by: 4 | 5 | ``` 6 | 'OIDC_CLAIMS_OPTIONS': { 7 | 'aud': { 8 | 'values': ['my_audience'], 9 | 'essential': True, 10 | } 11 | } 12 | ``` 13 | 14 | Please note the addition of `essential: True` in this dict. If you leave this out it will mean that _any_ audience will have access to your API. This is probably not what you want, so please make sure you add this to your settings if you're coming from a previous version. 15 | 16 | Also note that cryptography needs to be a least version 2.6 to work with the new authlib library. 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Byte Internet 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 | 23 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | autopep8 = "*" 8 | flake8 = "*" 9 | tox = "*" 10 | 11 | [packages] 12 | django = "*" 13 | djangorestframework = "*" 14 | authlib = "*" 15 | requests = "*" 16 | 17 | [requires] 18 | python_version = "3.10" 19 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "67c6febe5c649b95466ed26e798fb4ab9799a9c93b84c02b8ad8d5c4e8f87eb4" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.10" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "asgiref": { 20 | "hashes": [ 21 | "sha256:4ef1ab46b484e3c706329cedeff284a5d40824200638503f5768edb6de7d58e9", 22 | "sha256:ffc141aa908e6f175673e7b1b3b7af4fdb0ecb738fc5c8b88f69f055c2415214" 23 | ], 24 | "markers": "python_version >= '3.6'", 25 | "version": "==3.4.1" 26 | }, 27 | "authlib": { 28 | "hashes": [ 29 | "sha256:b83cf6360c8e92b0e9df0d1f32d675790bcc4e3c03977499b1eed24dcdef4252", 30 | "sha256:ecf4a7a9f2508c0bb07e93a752dd3c495cfaffc20e864ef0ffc95e3f40d2abaf" 31 | ], 32 | "index": "pypi", 33 | "version": "==0.15.5" 34 | }, 35 | "certifi": { 36 | "hashes": [ 37 | "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872", 38 | "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569" 39 | ], 40 | "version": "==2021.10.8" 41 | }, 42 | "cffi": { 43 | "hashes": [ 44 | "sha256:00c878c90cb53ccfaae6b8bc18ad05d2036553e6d9d1d9dbcf323bbe83854ca3", 45 | "sha256:0104fb5ae2391d46a4cb082abdd5c69ea4eab79d8d44eaaf79f1b1fd806ee4c2", 46 | "sha256:06c48159c1abed75c2e721b1715c379fa3200c7784271b3c46df01383b593636", 47 | "sha256:0808014eb713677ec1292301ea4c81ad277b6cdf2fdd90fd540af98c0b101d20", 48 | "sha256:10dffb601ccfb65262a27233ac273d552ddc4d8ae1bf93b21c94b8511bffe728", 49 | "sha256:14cd121ea63ecdae71efa69c15c5543a4b5fbcd0bbe2aad864baca0063cecf27", 50 | "sha256:17771976e82e9f94976180f76468546834d22a7cc404b17c22df2a2c81db0c66", 51 | "sha256:181dee03b1170ff1969489acf1c26533710231c58f95534e3edac87fff06c443", 52 | "sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0", 53 | "sha256:263cc3d821c4ab2213cbe8cd8b355a7f72a8324577dc865ef98487c1aeee2bc7", 54 | "sha256:2756c88cbb94231c7a147402476be2c4df2f6078099a6f4a480d239a8817ae39", 55 | "sha256:27c219baf94952ae9d50ec19651a687b826792055353d07648a5695413e0c605", 56 | "sha256:2a23af14f408d53d5e6cd4e3d9a24ff9e05906ad574822a10563efcef137979a", 57 | "sha256:31fb708d9d7c3f49a60f04cf5b119aeefe5644daba1cd2a0fe389b674fd1de37", 58 | "sha256:3415c89f9204ee60cd09b235810be700e993e343a408693e80ce7f6a40108029", 59 | "sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139", 60 | "sha256:3b96a311ac60a3f6be21d2572e46ce67f09abcf4d09344c49274eb9e0bf345fc", 61 | "sha256:3f7d084648d77af029acb79a0ff49a0ad7e9d09057a9bf46596dac9514dc07df", 62 | "sha256:41d45de54cd277a7878919867c0f08b0cf817605e4eb94093e7516505d3c8d14", 63 | "sha256:4238e6dab5d6a8ba812de994bbb0a79bddbdf80994e4ce802b6f6f3142fcc880", 64 | "sha256:45db3a33139e9c8f7c09234b5784a5e33d31fd6907800b316decad50af323ff2", 65 | "sha256:45e8636704eacc432a206ac7345a5d3d2c62d95a507ec70d62f23cd91770482a", 66 | "sha256:4958391dbd6249d7ad855b9ca88fae690783a6be9e86df65865058ed81fc860e", 67 | "sha256:4a306fa632e8f0928956a41fa8e1d6243c71e7eb59ffbd165fc0b41e316b2474", 68 | "sha256:57e9ac9ccc3101fac9d6014fba037473e4358ef4e89f8e181f8951a2c0162024", 69 | "sha256:59888172256cac5629e60e72e86598027aca6bf01fa2465bdb676d37636573e8", 70 | "sha256:5e069f72d497312b24fcc02073d70cb989045d1c91cbd53979366077959933e0", 71 | "sha256:64d4ec9f448dfe041705426000cc13e34e6e5bb13736e9fd62e34a0b0c41566e", 72 | "sha256:6dc2737a3674b3e344847c8686cf29e500584ccad76204efea14f451d4cc669a", 73 | "sha256:74fdfdbfdc48d3f47148976f49fab3251e550a8720bebc99bf1483f5bfb5db3e", 74 | "sha256:75e4024375654472cc27e91cbe9eaa08567f7fbdf822638be2814ce059f58032", 75 | "sha256:786902fb9ba7433aae840e0ed609f45c7bcd4e225ebb9c753aa39725bb3e6ad6", 76 | "sha256:8b6c2ea03845c9f501ed1313e78de148cd3f6cad741a75d43a29b43da27f2e1e", 77 | "sha256:91d77d2a782be4274da750752bb1650a97bfd8f291022b379bb8e01c66b4e96b", 78 | "sha256:91ec59c33514b7c7559a6acda53bbfe1b283949c34fe7440bcf917f96ac0723e", 79 | "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954", 80 | "sha256:a5263e363c27b653a90078143adb3d076c1a748ec9ecc78ea2fb916f9b861962", 81 | "sha256:abb9a20a72ac4e0fdb50dae135ba5e77880518e742077ced47eb1499e29a443c", 82 | "sha256:c2051981a968d7de9dd2d7b87bcb9c939c74a34626a6e2f8181455dd49ed69e4", 83 | "sha256:c21c9e3896c23007803a875460fb786118f0cdd4434359577ea25eb556e34c55", 84 | "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962", 85 | "sha256:d4d692a89c5cf08a8557fdeb329b82e7bf609aadfaed6c0d79f5a449a3c7c023", 86 | "sha256:da5db4e883f1ce37f55c667e5c0de439df76ac4cb55964655906306918e7363c", 87 | "sha256:e7022a66d9b55e93e1a845d8c9eba2a1bebd4966cd8bfc25d9cd07d515b33fa6", 88 | "sha256:ef1f279350da2c586a69d32fc8733092fd32cc8ac95139a00377841f59a3f8d8", 89 | "sha256:f54a64f8b0c8ff0b64d18aa76675262e1700f3995182267998c31ae974fbc382", 90 | "sha256:f5c7150ad32ba43a07c4479f40241756145a1f03b43480e058cfd862bf5041c7", 91 | "sha256:f6f824dc3bce0edab5f427efcfb1d63ee75b6fcb7282900ccaf925be84efb0fc", 92 | "sha256:fd8a250edc26254fe5b33be00402e6d287f562b6a5b2152dec302fa15bb3e997", 93 | "sha256:ffaa5c925128e29efbde7301d8ecaf35c8c60ffbcd6a1ffd3a552177c8e5e796" 94 | ], 95 | "version": "==1.15.0" 96 | }, 97 | "charset-normalizer": { 98 | "hashes": [ 99 | "sha256:1eecaa09422db5be9e29d7fc65664e6c33bd06f9ced7838578ba40d58bdf3721", 100 | "sha256:b0b883e8e874edfdece9c28f314e3dd5badf067342e42fb162203335ae61aa2c" 101 | ], 102 | "markers": "python_version >= '3'", 103 | "version": "==2.0.9" 104 | }, 105 | "cryptography": { 106 | "hashes": [ 107 | "sha256:0a817b961b46894c5ca8a66b599c745b9a3d9f822725221f0e0fe49dc043a3a3", 108 | "sha256:2d87cdcb378d3cfed944dac30596da1968f88fb96d7fc34fdae30a99054b2e31", 109 | "sha256:30ee1eb3ebe1644d1c3f183d115a8c04e4e603ed6ce8e394ed39eea4a98469ac", 110 | "sha256:391432971a66cfaf94b21c24ab465a4cc3e8bf4a939c1ca5c3e3a6e0abebdbcf", 111 | "sha256:39bdf8e70eee6b1c7b289ec6e5d84d49a6bfa11f8b8646b5b3dfe41219153316", 112 | "sha256:4caa4b893d8fad33cf1964d3e51842cd78ba87401ab1d2e44556826df849a8ca", 113 | "sha256:53e5c1dc3d7a953de055d77bef2ff607ceef7a2aac0353b5d630ab67f7423638", 114 | "sha256:596f3cd67e1b950bc372c33f1a28a0692080625592ea6392987dba7f09f17a94", 115 | "sha256:5d59a9d55027a8b88fd9fd2826c4392bd487d74bf628bb9d39beecc62a644c12", 116 | "sha256:6c0c021f35b421ebf5976abf2daacc47e235f8b6082d3396a2fe3ccd537ab173", 117 | "sha256:73bc2d3f2444bcfeac67dd130ff2ea598ea5f20b40e36d19821b4df8c9c5037b", 118 | "sha256:74d6c7e80609c0f4c2434b97b80c7f8fdfaa072ca4baab7e239a15d6d70ed73a", 119 | "sha256:7be0eec337359c155df191d6ae00a5e8bbb63933883f4f5dffc439dac5348c3f", 120 | "sha256:94ae132f0e40fe48f310bba63f477f14a43116f05ddb69d6fa31e93f05848ae2", 121 | "sha256:bb5829d027ff82aa872d76158919045a7c1e91fbf241aec32cb07956e9ebd3c9", 122 | "sha256:ca238ceb7ba0bdf6ce88c1b74a87bffcee5afbfa1e41e173b1ceb095b39add46", 123 | "sha256:ca28641954f767f9822c24e927ad894d45d5a1e501767599647259cbf030b903", 124 | "sha256:e0344c14c9cb89e76eb6a060e67980c9e35b3f36691e15e1b7a9e58a0a6c6dc3", 125 | "sha256:ebc15b1c22e55c4d5566e3ca4db8689470a0ca2babef8e3a9ee057a8b82ce4b1", 126 | "sha256:ec63da4e7e4a5f924b90af42eddf20b698a70e58d86a72d943857c4c6045b3ee" 127 | ], 128 | "markers": "python_version >= '3.6'", 129 | "version": "==36.0.1" 130 | }, 131 | "django": { 132 | "hashes": [ 133 | "sha256:59304646ebc6a77b9b6a59adc67d51ecb03c5e3d63ed1f14c909cdfda84e8010", 134 | "sha256:d5a8a14da819a8b9237ee4d8c78dfe056ff6e8a7511987be627192225113ee75" 135 | ], 136 | "index": "pypi", 137 | "version": "==4.0" 138 | }, 139 | "djangorestframework": { 140 | "hashes": [ 141 | "sha256:0c33407ce23acc68eca2a6e46424b008c9c02eceb8cf18581921d0092bc1f2ee", 142 | "sha256:24c4bf58ed7e85d1fe4ba250ab2da926d263cd57d64b03e8dcef0ac683f8b1aa" 143 | ], 144 | "index": "pypi", 145 | "version": "==3.13.1" 146 | }, 147 | "idna": { 148 | "hashes": [ 149 | "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", 150 | "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" 151 | ], 152 | "markers": "python_version >= '3'", 153 | "version": "==3.3" 154 | }, 155 | "pycparser": { 156 | "hashes": [ 157 | "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9", 158 | "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206" 159 | ], 160 | "version": "==2.21" 161 | }, 162 | "pytz": { 163 | "hashes": [ 164 | "sha256:3672058bc3453457b622aab7a1c3bfd5ab0bdae451512f6cf25f64ed37f5b87c", 165 | "sha256:acad2d8b20a1af07d4e4c9d2e9285c5ed9104354062f275f3fcd88dcef4f1326" 166 | ], 167 | "version": "==2021.3" 168 | }, 169 | "requests": { 170 | "hashes": [ 171 | "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24", 172 | "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7" 173 | ], 174 | "index": "pypi", 175 | "version": "==2.26.0" 176 | }, 177 | "sqlparse": { 178 | "hashes": [ 179 | "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae", 180 | "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d" 181 | ], 182 | "markers": "python_version >= '3.5'", 183 | "version": "==0.4.2" 184 | }, 185 | "urllib3": { 186 | "hashes": [ 187 | "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece", 188 | "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844" 189 | ], 190 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", 191 | "version": "==1.26.7" 192 | } 193 | }, 194 | "develop": { 195 | "autopep8": { 196 | "hashes": [ 197 | "sha256:44f0932855039d2c15c4510d6df665e4730f2b8582704fa48f9c55bd3e17d979", 198 | "sha256:ed77137193bbac52d029a52c59bec1b0629b5a186c495f1eb21b126ac466083f" 199 | ], 200 | "index": "pypi", 201 | "version": "==1.6.0" 202 | }, 203 | "distlib": { 204 | "hashes": [ 205 | "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b", 206 | "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579" 207 | ], 208 | "version": "==0.3.4" 209 | }, 210 | "filelock": { 211 | "hashes": [ 212 | "sha256:38b4f4c989f9d06d44524df1b24bd19e167d851f19b50bf3e3559952dddc5b80", 213 | "sha256:cf0fc6a2f8d26bd900f19bf33915ca70ba4dd8c56903eeb14e1e7a2fd7590146" 214 | ], 215 | "markers": "python_version >= '3.7'", 216 | "version": "==3.4.2" 217 | }, 218 | "flake8": { 219 | "hashes": [ 220 | "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d", 221 | "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d" 222 | ], 223 | "index": "pypi", 224 | "version": "==4.0.1" 225 | }, 226 | "mccabe": { 227 | "hashes": [ 228 | "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", 229 | "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" 230 | ], 231 | "version": "==0.6.1" 232 | }, 233 | "packaging": { 234 | "hashes": [ 235 | "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb", 236 | "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522" 237 | ], 238 | "markers": "python_version >= '3.6'", 239 | "version": "==21.3" 240 | }, 241 | "platformdirs": { 242 | "hashes": [ 243 | "sha256:1d7385c7db91728b83efd0ca99a5afb296cab9d0ed8313a45ed8ba17967ecfca", 244 | "sha256:440633ddfebcc36264232365d7840a970e75e1018d15b4327d11f91909045fda" 245 | ], 246 | "markers": "python_version >= '3.7'", 247 | "version": "==2.4.1" 248 | }, 249 | "pluggy": { 250 | "hashes": [ 251 | "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159", 252 | "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3" 253 | ], 254 | "markers": "python_version >= '3.6'", 255 | "version": "==1.0.0" 256 | }, 257 | "py": { 258 | "hashes": [ 259 | "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", 260 | "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378" 261 | ], 262 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 263 | "version": "==1.11.0" 264 | }, 265 | "pycodestyle": { 266 | "hashes": [ 267 | "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20", 268 | "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f" 269 | ], 270 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 271 | "version": "==2.8.0" 272 | }, 273 | "pyflakes": { 274 | "hashes": [ 275 | "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c", 276 | "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e" 277 | ], 278 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 279 | "version": "==2.4.0" 280 | }, 281 | "pyparsing": { 282 | "hashes": [ 283 | "sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4", 284 | "sha256:d9bdec0013ef1eb5a84ab39a3b3868911598afa494f5faa038647101504e2b81" 285 | ], 286 | "markers": "python_version >= '3.6'", 287 | "version": "==3.0.6" 288 | }, 289 | "six": { 290 | "hashes": [ 291 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 292 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 293 | ], 294 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 295 | "version": "==1.16.0" 296 | }, 297 | "toml": { 298 | "hashes": [ 299 | "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", 300 | "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" 301 | ], 302 | "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", 303 | "version": "==0.10.2" 304 | }, 305 | "tox": { 306 | "hashes": [ 307 | "sha256:67e0e32c90e278251fea45b696d0fef3879089ccbe979b0c556d35d5a70e2993", 308 | "sha256:be3362472a33094bce26727f5f771ca0facf6dafa217f65875314e9a6600c95c" 309 | ], 310 | "index": "pypi", 311 | "version": "==3.24.5" 312 | }, 313 | "virtualenv": { 314 | "hashes": [ 315 | "sha256:a5bb9afc076462ea736b0c060829ed6aef707413d0e5946294cc26e3c821436a", 316 | "sha256:d51ae01ef49e7de4d2b9d85b4926ac5aabc3f3879a4b4e4c4a8027fa2f0e4f6a" 317 | ], 318 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 319 | "version": "==20.12.1" 320 | } 321 | } 322 | } 323 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenID Connect authentication for Django Rest Framework 2 | 3 | This package contains an authentication mechanism for authenticating 4 | users of a REST API using tokens obtained from OpenID Connect. 5 | 6 | Currently, it only supports JWT and Bearer tokens. JWT tokens will be 7 | validated against the public keys of an OpenID connect authorization 8 | service. Bearer tokens are used to retrieve the OpenID UserInfo for a 9 | user to identify him. 10 | 11 | # Installation 12 | 13 | Install using pip: 14 | 15 | ```sh 16 | pip install drf-oidc-auth 17 | ``` 18 | 19 | Configure authentication for Django REST Framework in settings.py: 20 | 21 | ```py 22 | REST_FRAMEWORK = { 23 | 'DEFAULT_PERMISSION_CLASSES': ( 24 | 'rest_framework.permissions.IsAuthenticated', 25 | ), 26 | 'DEFAULT_AUTHENTICATION_CLASSES': ( 27 | # ... 28 | 'oidc_auth.authentication.JSONWebTokenAuthentication', 29 | 'oidc_auth.authentication.BearerTokenAuthentication', 30 | ), 31 | } 32 | ``` 33 | 34 | And configure the module itself in settings.py: 35 | ```py 36 | OIDC_AUTH = { 37 | # Specify OpenID Connect endpoint. Configuration will be 38 | # automatically done based on the discovery document found 39 | # at /.well-known/openid-configuration 40 | 'OIDC_ENDPOINT': 'https://accounts.google.com', 41 | 42 | # The Claims Options can now be defined by a static string. 43 | # ref: https://docs.authlib.org/en/latest/jose/jwt.html#jwt-payload-claims-validation 44 | # The old OIDC_AUDIENCES option is removed in favor of this new option. 45 | # `aud` is only required, when you set it as an essential claim. 46 | 'OIDC_CLAIMS_OPTIONS': { 47 | 'aud': { 48 | 'values': ['myapp'], 49 | 'essential': True, 50 | } 51 | }, 52 | 53 | # (Optional) Function that resolves id_token into user. 54 | # This function receives a request and an id_token dict and expects to 55 | # return a User object. The default implementation tries to find the user 56 | # based on username (natural key) taken from the 'sub'-claim of the 57 | # id_token. 58 | 'OIDC_RESOLVE_USER_FUNCTION': 'oidc_auth.authentication.get_user_by_id', 59 | 60 | # (Optional) Number of seconds in the past valid tokens can be 61 | # issued (default 600) 62 | 'OIDC_LEEWAY': 600, 63 | 64 | # (Optional) Time before signing keys will be refreshed (default 24 hrs) 65 | 'OIDC_JWKS_EXPIRATION_TIME': 24*60*60, 66 | 67 | # (Optional) Time before bearer token validity is verified again (default 10 minutes) 68 | 'OIDC_BEARER_TOKEN_EXPIRATION_TIME': 10*60, 69 | 70 | # (Optional) Token prefix in JWT authorization header (default 'JWT') 71 | 'JWT_AUTH_HEADER_PREFIX': 'JWT', 72 | 73 | # (Optional) Token prefix in Bearer authorization header (default 'Bearer') 74 | 'BEARER_AUTH_HEADER_PREFIX': 'Bearer', 75 | 76 | # (Optional) Which Django cache to use 77 | 'OIDC_CACHE_NAME': 'default', 78 | 79 | # (Optional) A cache key prefix when storing and retrieving cached values 80 | 'OIDC_CACHE_PREFIX': 'oidc_auth.', 81 | } 82 | ``` 83 | 84 | # Running tests 85 | 86 | ```sh 87 | pip install tox 88 | tox 89 | ``` 90 | 91 | ## Mocking authentication 92 | 93 | There's a `AuthenticationTestCaseMixin` provided in the `oidc_auth.test` module, which you 94 | can use for testing authentication like so: 95 | ```python 96 | from oidc_auth.test import AuthenticationTestCaseMixin 97 | from django.test import TestCase 98 | 99 | class MyTestCase(AuthenticationTestCaseMixin, TestCase): 100 | def test_example_cache_of_valid_bearer_token(self): 101 | self.responder.set_response( 102 | 'http://example.com/userinfo', {'sub': self.user.username}) 103 | auth = 'Bearer egergerg' 104 | resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) 105 | self.assertEqual(resp.status_code, 200) 106 | 107 | # Token expires, but validity is cached 108 | self.responder.set_response('http://example.com/userinfo', "", 401) 109 | resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) 110 | self.assertEqual(resp.status_code, 200) 111 | 112 | def test_example_using_invalid_bearer_token(self): 113 | self.responder.set_response('http://example.com/userinfo', "", 401) 114 | auth = 'Bearer hjikasdf' 115 | resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) 116 | self.assertEqual(resp.status_code, 401) 117 | ``` 118 | 119 | # References 120 | 121 | * Requires [Django REST Framework](http://www.django-rest-framework.org/) 122 | * And of course [Django](https://www.djangoproject.com/) 123 | * Inspired on [REST framework JWT Auth](https://github.com/GetBlimp/django-rest-framework-jwt) 124 | -------------------------------------------------------------------------------- /oidc_auth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ByteInternet/drf-oidc-auth/a9ab291337edac3e7e55b4a52609a48185665cd9/oidc_auth/__init__.py -------------------------------------------------------------------------------- /oidc_auth/authentication.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | 4 | import requests 5 | from authlib.jose import JsonWebKey, jwt 6 | from authlib.jose.errors import (BadSignatureError, DecodeError, 7 | ExpiredTokenError, JoseError) 8 | from authlib.oidc.core.claims import IDToken 9 | from authlib.oidc.discovery import get_well_known_url 10 | from django.contrib.auth import get_user_model 11 | from django.utils.encoding import smart_str 12 | from django.utils.functional import cached_property 13 | from django.utils.translation import gettext as _ 14 | from requests import request 15 | from requests.exceptions import HTTPError 16 | from rest_framework.authentication import (BaseAuthentication, 17 | get_authorization_header) 18 | from rest_framework.exceptions import AuthenticationFailed 19 | 20 | from .settings import api_settings 21 | from .util import cache 22 | 23 | logging.basicConfig() 24 | logger = logging.getLogger(__name__) 25 | 26 | 27 | def get_user_by_id(request, id_token): 28 | User = get_user_model() 29 | try: 30 | user = User.objects.get_by_natural_key(id_token.get('sub')) 31 | except User.DoesNotExist: 32 | msg = _('Invalid Authorization header. User not found.') 33 | raise AuthenticationFailed(msg) 34 | return user 35 | 36 | 37 | class DRFIDToken(IDToken): 38 | 39 | def validate_exp(self, now, leeway): 40 | super(DRFIDToken, self).validate_exp(now, leeway) 41 | if now > self['exp']: 42 | msg = _('Invalid Authorization header. JWT has expired.') 43 | raise AuthenticationFailed(msg) 44 | 45 | def validate_iat(self, now, leeway): 46 | super(DRFIDToken, self).validate_iat(now, leeway) 47 | if self['iat'] < leeway: 48 | msg = _('Invalid Authorization header. JWT too old.') 49 | raise AuthenticationFailed(msg) 50 | 51 | 52 | class BaseOidcAuthentication(BaseAuthentication): 53 | @property 54 | @cache(ttl=api_settings.OIDC_BEARER_TOKEN_EXPIRATION_TIME) 55 | def oidc_config(self): 56 | return requests.get( 57 | get_well_known_url( 58 | api_settings.OIDC_ENDPOINT, 59 | external=True 60 | ) 61 | ).json() 62 | 63 | 64 | class BearerTokenAuthentication(BaseOidcAuthentication): 65 | www_authenticate_realm = 'api' 66 | 67 | def authenticate(self, request): 68 | bearer_token = self.get_bearer_token(request) 69 | if bearer_token is None: 70 | return None 71 | 72 | try: 73 | userinfo = self.get_userinfo(bearer_token) 74 | except HTTPError: 75 | msg = _('Invalid Authorization header. Unable to verify bearer token') 76 | raise AuthenticationFailed(msg) 77 | 78 | user = api_settings.OIDC_RESOLVE_USER_FUNCTION(request, userinfo) 79 | 80 | return user, userinfo 81 | 82 | def get_bearer_token(self, request): 83 | auth = get_authorization_header(request).split() 84 | auth_header_prefix = api_settings.BEARER_AUTH_HEADER_PREFIX.lower() 85 | if not auth or smart_str(auth[0].lower()) != auth_header_prefix: 86 | return None 87 | 88 | if len(auth) == 1: 89 | msg = _('Invalid Authorization header. No credentials provided') 90 | raise AuthenticationFailed(msg) 91 | elif len(auth) > 2: 92 | msg = _( 93 | 'Invalid Authorization header. Credentials string should not contain spaces.') 94 | raise AuthenticationFailed(msg) 95 | 96 | return auth[1] 97 | 98 | @cache(ttl=api_settings.OIDC_BEARER_TOKEN_EXPIRATION_TIME) 99 | def get_userinfo(self, token): 100 | userinfo_endpoint = self.oidc_config.get('userinfo_endpoint', api_settings.USERINFO_ENDPOINT) 101 | if not userinfo_endpoint: 102 | raise AuthenticationFailed(_('Invalid userinfo_endpoint URL. Did not find a URL from OpenID connect ' 103 | 'discovery metadata nor settings.OIDC_AUTH.USERINFO_ENDPOINT.')) 104 | 105 | response = requests.get(userinfo_endpoint, headers={ 106 | 'Authorization': 'Bearer {0}'.format(token.decode('ascii'))}) 107 | response.raise_for_status() 108 | 109 | return response.json() 110 | 111 | 112 | class JSONWebTokenAuthentication(BaseOidcAuthentication): 113 | """Token based authentication using the JSON Web Token standard""" 114 | 115 | www_authenticate_realm = 'api' 116 | 117 | @property 118 | def claims_options(self): 119 | _claims_options = { 120 | 'iss': { 121 | 'essential': True, 122 | 'values': [self.issuer] 123 | } 124 | } 125 | for key, value in api_settings.OIDC_CLAIMS_OPTIONS.items(): 126 | _claims_options[key] = value 127 | return _claims_options 128 | 129 | def authenticate(self, request): 130 | jwt_value = self.get_jwt_value(request) 131 | if jwt_value is None: 132 | return None 133 | payload = self.decode_jwt(jwt_value) 134 | self.validate_claims(payload) 135 | 136 | user = api_settings.OIDC_RESOLVE_USER_FUNCTION(request, payload) 137 | 138 | return user, payload 139 | 140 | def get_jwt_value(self, request): 141 | auth = get_authorization_header(request).split() 142 | auth_header_prefix = api_settings.JWT_AUTH_HEADER_PREFIX.lower() 143 | 144 | if not auth or smart_str(auth[0].lower()) != auth_header_prefix: 145 | return None 146 | 147 | if len(auth) == 1: 148 | msg = _('Invalid Authorization header. No credentials provided') 149 | raise AuthenticationFailed(msg) 150 | elif len(auth) > 2: 151 | msg = _( 152 | 'Invalid Authorization header. Credentials string should not contain spaces.') 153 | raise AuthenticationFailed(msg) 154 | 155 | return auth[1] 156 | 157 | def jwks(self): 158 | return JsonWebKey.import_key_set(self.jwks_data()) 159 | 160 | @cache(ttl=api_settings.OIDC_JWKS_EXPIRATION_TIME) 161 | def jwks_data(self): 162 | r = request("GET", self.oidc_config['jwks_uri'], allow_redirects=True) 163 | r.raise_for_status() 164 | return r.json() 165 | 166 | @cached_property 167 | def issuer(self): 168 | return self.oidc_config['issuer'] 169 | 170 | def decode_jwt(self, jwt_value): 171 | try: 172 | id_token = jwt.decode( 173 | jwt_value.decode('ascii'), 174 | self.jwks(), 175 | claims_cls=DRFIDToken, 176 | claims_options=self.claims_options 177 | ) 178 | except (BadSignatureError, DecodeError): 179 | msg = _( 180 | 'Invalid Authorization header. JWT Signature verification failed.') 181 | logger.exception(msg) 182 | raise AuthenticationFailed(msg) 183 | except AssertionError: 184 | msg = _( 185 | 'Invalid Authorization header. Please provide base64 encoded ID Token' 186 | ) 187 | raise AuthenticationFailed(msg) 188 | 189 | return id_token 190 | 191 | def validate_claims(self, id_token): 192 | try: 193 | id_token.validate( 194 | now=int(time.time()), 195 | leeway=int(time.time()-api_settings.OIDC_LEEWAY) 196 | ) 197 | except ExpiredTokenError: 198 | msg = _('Invalid Authorization header. JWT has expired.') 199 | raise AuthenticationFailed(msg) 200 | except JoseError as e: 201 | msg = _(str(type(e)) + str(e)) 202 | raise AuthenticationFailed(msg) 203 | 204 | def authenticate_header(self, request): 205 | return 'JWT realm="{0}"'.format(self.www_authenticate_realm) 206 | -------------------------------------------------------------------------------- /oidc_auth/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from rest_framework.settings import APISettings 3 | 4 | USER_SETTINGS = getattr(settings, 'OIDC_AUTH', None) 5 | 6 | DEFAULTS = { 7 | 'OIDC_ENDPOINT': None, 8 | 9 | # Currently unimplemented 10 | 'OIDC_ENDPOINTS': [], 11 | 12 | # The Claims Options can now be defined by a static string. 13 | # ref: https://docs.authlib.org/en/latest/jose/jwt.html#jwt-payload-claims-validation 14 | 'OIDC_CLAIMS_OPTIONS': { 15 | 'aud': { 16 | 'essential': True, 17 | } 18 | }, 19 | 20 | # Number of seconds in the past valid tokens can be issued 21 | 'OIDC_LEEWAY': 600, 22 | 23 | # Time before JWKS will be refreshed 24 | 'OIDC_JWKS_EXPIRATION_TIME': 24 * 60 * 60, 25 | 26 | # Function to resolve user from request and token or userinfo 27 | 'OIDC_RESOLVE_USER_FUNCTION': 'oidc_auth.authentication.get_user_by_id', 28 | 29 | # Time before bearer token validity is verified again 30 | 'OIDC_BEARER_TOKEN_EXPIRATION_TIME': 600, 31 | 32 | 'JWT_AUTH_HEADER_PREFIX': 'JWT', 33 | 'BEARER_AUTH_HEADER_PREFIX': 'Bearer', 34 | 35 | # The Django cache to use 36 | 'OIDC_CACHE_NAME': 'default', 37 | 'OIDC_CACHE_PREFIX': 'oidc_auth.', 38 | 39 | # URL of the OpenID Provider's UserInfo Endpoint 40 | 'USERINFO_ENDPOINT': None, 41 | } 42 | 43 | # List of settings that may be in string import notation. 44 | IMPORT_STRINGS = ( 45 | 'OIDC_RESOLVE_USER_FUNCTION', 46 | ) 47 | 48 | api_settings = APISettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS) 49 | -------------------------------------------------------------------------------- /oidc_auth/test.py: -------------------------------------------------------------------------------- 1 | import json 2 | from django.contrib.auth import get_user_model 3 | from requests.models import Response 4 | from authlib.jose import JsonWebToken, KeySet, RSAKey 5 | try: 6 | from unittest.mock import patch, Mock 7 | except ImportError: 8 | from mock import patch, Mock 9 | 10 | key = RSAKey.generate_key(is_private=True) 11 | 12 | 13 | def make_id_token(sub, 14 | iss='http://example.com', 15 | aud='you', 16 | exp=999999999999, # tests will start failing in September 33658 17 | iat=999999999999, 18 | **kwargs): 19 | return make_jwt( 20 | dict( 21 | iss=iss, 22 | aud=aud, 23 | exp=exp, 24 | iat=iat, 25 | sub=str(sub), 26 | **kwargs 27 | ) 28 | ).decode('ascii') 29 | 30 | 31 | def make_jwt(payload): 32 | jwt = JsonWebToken(['RS256']) 33 | jws = jwt.encode( 34 | {'alg': 'RS256', 'kid': key.as_dict(add_kid=True).get('kid')}, payload, key=key) 35 | return jws 36 | 37 | 38 | class FakeRequests(object): 39 | def __init__(self): 40 | self.responses = {} 41 | 42 | def set_response(self, url, content, status_code=200): 43 | self.responses[url] = (status_code, json.dumps(content)) 44 | 45 | def get(self, url, *args, **kwargs): 46 | wanted_response = self.responses.get(url) 47 | if not wanted_response: 48 | status_code, content = 404, '' 49 | else: 50 | status_code, content = wanted_response 51 | 52 | response = Response() 53 | response._content = content.encode('utf-8') 54 | response.status_code = status_code 55 | 56 | return response 57 | 58 | 59 | class AuthenticationTestCaseMixin(object): 60 | username = 'henk' 61 | 62 | def patch(self, thing_to_mock, **kwargs): 63 | patcher = patch(thing_to_mock, **kwargs) 64 | patched = patcher.start() 65 | self.addCleanup(patcher.stop) 66 | return patched 67 | 68 | def setUp(self): 69 | self.user, _ = get_user_model().objects.get_or_create(username=self.username) 70 | self.responder = FakeRequests() 71 | self.responder.set_response("http://example.com/.well-known/openid-configuration", 72 | {"jwks_uri": "http://example.com/jwks", 73 | "issuer": "http://example.com", 74 | "userinfo_endpoint": "http://example.com/userinfo"}) 75 | self.mock_get = self.patch('requests.get') 76 | self.mock_get.side_effect = self.responder.get 77 | keys = KeySet(keys=[key]) 78 | self.patch( 79 | 'oidc_auth.authentication.request', 80 | return_value=Mock( 81 | status_code=200, 82 | json=keys.as_json 83 | ) 84 | ) 85 | -------------------------------------------------------------------------------- /oidc_auth/util.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | from django.core.cache import caches 4 | 5 | from .settings import api_settings 6 | 7 | 8 | class cache(object): 9 | """ Cache decorator that memoizes the return value of a method for some time. 10 | 11 | Increment the cache_version everytime your method's implementation changes 12 | in such a way that it returns values that are not backwards compatible. 13 | For more information, see the Django cache documentation: 14 | https://docs.djangoproject.com/en/2.2/topics/cache/#cache-versioning 15 | """ 16 | 17 | def __init__(self, ttl, cache_version=1): 18 | self.ttl = ttl 19 | self.cache_version = cache_version 20 | 21 | def __call__(self, fn): 22 | @functools.wraps(fn) 23 | def wrapped(this, *args): 24 | cache = caches[api_settings.OIDC_CACHE_NAME] 25 | key = api_settings.OIDC_CACHE_PREFIX + '.'.join([fn.__name__] + list(map(str, args))) 26 | cached_value = cache.get(key, version=self.cache_version) 27 | if not cached_value: 28 | cached_value = fn(this, *args) 29 | cache.set(key, cached_value, timeout=self.ttl, version=self.cache_version) 30 | return cached_value 31 | 32 | return wrapped 33 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | [flake8] 4 | max-line-length=100 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='drf-oidc-auth', 5 | version='3.0.0', 6 | packages=['oidc_auth'], 7 | url='https://github.com/ByteInternet/drf-oidc-auth', 8 | license='MIT', 9 | author='Maarten van Schaik', 10 | author_email='support@byte.nl', 11 | description='OpenID Connect authentication for Django Rest Framework', 12 | install_requires=[ 13 | 'authlib>=0.15.0', 14 | 'cryptography>=2.6', 15 | 'django>=2.2.0', 16 | 'djangorestframework>=3.11.0', 17 | 'requests>=2.20.0' 18 | ], 19 | classifiers=[ 20 | 'Development Status :: 4 - Beta', 21 | 'Environment :: Web Environment', 22 | 'Framework :: Django', 23 | 'Intended Audience :: Developers', 24 | 'License :: OSI Approved :: MIT License', 25 | 'Operating System :: OS Independent', 26 | 'Programming Language :: Python', 27 | 'Programming Language :: Python :: 3.7', 28 | 'Programming Language :: Python :: 3.8', 29 | 'Programming Language :: Python :: 3.9', 30 | 'Programming Language :: Python :: 3.10', 31 | 'Topic :: Internet', 32 | 'Topic :: Internet :: WWW/HTTP', 33 | 'Topic :: Security', 34 | ], 35 | ) 36 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ByteInternet/drf-oidc-auth/a9ab291337edac3e7e55b4a52609a48185665cd9/tests/__init__.py -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = 'secret' 2 | DATABASES = { 3 | 'default': { 4 | 'ENGINE': 'django.db.backends.sqlite3', 5 | 'NAME': ':memory:' 6 | } 7 | } 8 | INSTALLED_APPS = ( 9 | 'django.contrib.auth', 10 | 'django.contrib.contenttypes', 11 | ) 12 | ROOT_URLCONF = 'tests.test_authentication' 13 | OIDC_AUTH = { 14 | 'OIDC_ENDPOINT': 'http://example.com', 15 | 'OIDC_CLAIMS_OPTIONS': { 16 | 'aud': { 17 | 'values': ['you'], 18 | 'essential': True, 19 | } 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /tests/test_authentication.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from authlib.jose.errors import BadSignatureError, DecodeError 4 | from django.http import HttpResponse 5 | from django.test import TestCase 6 | from django.urls import re_path as url 7 | from oidc_auth.authentication import (BearerTokenAuthentication, 8 | JSONWebTokenAuthentication) 9 | from oidc_auth.test import AuthenticationTestCaseMixin, make_id_token 10 | from rest_framework.exceptions import AuthenticationFailed 11 | from rest_framework.permissions import IsAuthenticated 12 | from rest_framework.views import APIView 13 | 14 | if sys.version_info > (3,): 15 | long = int 16 | else: 17 | class ConnectionError(OSError): 18 | pass 19 | 20 | try: 21 | from unittest.mock import Mock, PropertyMock, patch 22 | except ImportError: 23 | from mock import Mock, PropertyMock, patch 24 | 25 | 26 | class MockView(APIView): 27 | permission_classes = (IsAuthenticated,) 28 | authentication_classes = ( 29 | JSONWebTokenAuthentication, 30 | BearerTokenAuthentication 31 | ) 32 | 33 | def get(self, request): 34 | return HttpResponse('a') 35 | 36 | 37 | urlpatterns = [ 38 | url(r'^test/$', MockView.as_view(), name="testview") 39 | ] 40 | 41 | 42 | class TestBearerAuthentication(AuthenticationTestCaseMixin, TestCase): 43 | urls = __name__ 44 | 45 | def setUp(self): 46 | super(TestBearerAuthentication, self).setUp() 47 | self.openid_configuration = { 48 | 'issuer': 'http://accounts.example.com/dex', 49 | 'authorization_endpoint': 'http://accounts.example.com/dex/auth', 50 | 'token_endpoint': 'http://accounts.example.com/dex/token', 51 | 'jwks_uri': 'http://accounts.example.com/dex/keys', 52 | 'response_types_supported': ['code'], 53 | 'subject_types_supported': ['public'], 54 | 'id_token_signing_alg_values_supported': ['RS256'], 55 | 'scopes_supported': ['openid', 'email', 'groups', 'profile', 'offline_access'], 56 | 'token_endpoint_auth_methods_supported': ['client_secret_basic'], 57 | 'claims_supported': [ 58 | 'aud', 'email', 'email_verified', 'exp', 'iat', 'iss', 'locale', 59 | 'name', 'sub' 60 | ], 61 | 'userinfo_endpoint': 'http://sellers.example.com/v1/sellers/' 62 | } 63 | 64 | def test_using_valid_bearer_token(self): 65 | self.responder.set_response( 66 | 'http://example.com/userinfo', {'sub': self.user.username}) 67 | auth = 'Bearer abcdefg' 68 | resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) 69 | self.assertEqual(resp.content.decode(), 'a') 70 | self.assertEqual(resp.status_code, 200) 71 | self.mock_get.assert_called_with( 72 | 'http://example.com/userinfo', headers={'Authorization': auth}) 73 | 74 | def test_cache_of_valid_bearer_token(self): 75 | self.responder.set_response( 76 | 'http://example.com/userinfo', {'sub': self.user.username}) 77 | auth = 'Bearer egergerg' 78 | resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) 79 | self.assertEqual(resp.status_code, 200) 80 | 81 | # Token expires, but validity is cached 82 | self.responder.set_response('http://example.com/userinfo', "", 401) 83 | resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) 84 | self.assertEqual(resp.status_code, 200) 85 | 86 | def test_using_invalid_bearer_token(self): 87 | self.responder.set_response('http://example.com/userinfo', "", 401) 88 | auth = 'Bearer hjikasdf' 89 | resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) 90 | self.assertEqual(resp.status_code, 401) 91 | 92 | def test_cache_of_invalid_bearer_token(self): 93 | self.responder.set_response('http://example.com/userinfo', "", 401) 94 | auth = 'Bearer feegrgeregreg' 95 | resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) 96 | self.assertEqual(resp.status_code, 401) 97 | 98 | # Token becomes valid 99 | self.responder.set_response( 100 | 'http://example.com/userinfo', {'sub': self.user.username}) 101 | resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) 102 | self.assertEqual(resp.status_code, 200) 103 | 104 | def test_using_malformed_bearer_token(self): 105 | auth = 'Bearer abc def' 106 | resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) 107 | self.assertEqual(resp.status_code, 401) 108 | 109 | def test_using_missing_bearer_token(self): 110 | auth = 'Bearer' 111 | resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) 112 | self.assertEqual(resp.status_code, 401) 113 | 114 | def test_using_inaccessible_userinfo_endpoint(self): 115 | self.mock_get.side_effect = ConnectionError 116 | auth = 'Bearer' 117 | resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) 118 | self.assertEqual(resp.status_code, 401) 119 | 120 | def test_get_user_info_endpoint(self): 121 | with patch('oidc_auth.authentication.BaseOidcAuthentication.oidc_config', new_callable=PropertyMock) as oidc_config_mock: 122 | oidc_config_mock.return_value = self.openid_configuration 123 | authentication = BearerTokenAuthentication() 124 | response_mock = Mock(return_value=Mock(status_code=200, 125 | json=Mock(return_value={}), 126 | raise_for_status=Mock(return_value=None))) 127 | with patch('oidc_auth.authentication.requests.get', response_mock): 128 | result = authentication.get_userinfo(b'token') 129 | assert result == {} 130 | 131 | def test_get_user_info_endpoint_with_missing_field(self): 132 | self.openid_configuration.pop('userinfo_endpoint') 133 | with patch('oidc_auth.authentication.BaseOidcAuthentication.oidc_config', new_callable=PropertyMock) as oidc_config_mock: 134 | oidc_config_mock.return_value = self.openid_configuration 135 | authentication = BearerTokenAuthentication() 136 | with self.assertRaisesMessage(AuthenticationFailed, 'userinfo_endpoint'): 137 | authentication.get_userinfo(b'faketoken') 138 | 139 | 140 | class TestJWTAuthentication(AuthenticationTestCaseMixin, TestCase): 141 | urls = __name__ 142 | 143 | def test_using_valid_jwt(self): 144 | auth = 'JWT ' + make_id_token(self.user.username) 145 | resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) 146 | self.assertEqual(resp.status_code, 200) 147 | self.assertEqual(resp.content.decode(), 'a') 148 | 149 | def test_without_jwt(self): 150 | resp = self.client.get('/test/') 151 | self.assertEqual(resp.status_code, 401) 152 | 153 | def test_with_invalid_jwt(self): 154 | auth = 'JWT e30=' 155 | resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) 156 | self.assertEqual(resp.status_code, 401) 157 | 158 | def test_with_invalid_auth_header(self): 159 | auth = 'Bearer 12345' 160 | resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) 161 | self.assertEqual(resp.status_code, 401) 162 | 163 | def test_with_expired_jwt(self): 164 | auth = 'JWT ' + make_id_token(self.user.username, exp=13151351) 165 | resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) 166 | self.assertEqual(resp.status_code, 401) 167 | 168 | def test_with_old_jwt(self): 169 | auth = 'JWT ' + make_id_token(self.user.username, iat=13151351) 170 | resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) 171 | self.assertEqual(resp.status_code, 401) 172 | 173 | def test_with_invalid_issuer(self): 174 | auth = 'JWT ' + \ 175 | make_id_token(self.user.username, iss='http://something.com') 176 | resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) 177 | self.assertEqual(resp.status_code, 401) 178 | 179 | def test_with_invalid_audience(self): 180 | auth = 'JWT ' + make_id_token(self.user.username, aud='somebody') 181 | resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) 182 | self.assertEqual(resp.status_code, 401) 183 | 184 | def test_with_too_new_jwt(self): 185 | auth = 'JWT ' + make_id_token(self.user.username, nbf=999999999999) 186 | resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) 187 | self.assertEqual(resp.status_code, 401) 188 | 189 | def test_with_unknown_subject(self): 190 | auth = 'JWT ' + make_id_token(self.user.username + 'x') 191 | resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) 192 | self.assertEqual(resp.status_code, 401) 193 | 194 | def test_with_multiple_audiences(self): 195 | auth = 'JWT ' + make_id_token(self.user.username, aud=['you', 'me']) 196 | resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) 197 | self.assertEqual(resp.status_code, 200) 198 | 199 | def test_with_invalid_multiple_audiences(self): 200 | auth = 'JWT ' + make_id_token(self.user.username, aud=['we', 'me']) 201 | resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) 202 | self.assertEqual(resp.status_code, 401) 203 | 204 | def test_with_multiple_audiences_and_authorized_party(self): 205 | auth = 'JWT ' + \ 206 | make_id_token(self.user.username, aud=['you', 'me'], azp='you') 207 | resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) 208 | self.assertEqual(resp.status_code, 200) 209 | 210 | def test_with_invalid_signature(self): 211 | auth = 'JWT ' + make_id_token(self.user.username) 212 | resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth + 'x') 213 | self.assertEqual(resp.status_code, 401) 214 | 215 | @patch('oidc_auth.authentication.jwt.decode') 216 | @patch('oidc_auth.authentication.logger') 217 | def test_decode_jwt_logs_exception_message_when_decode_throws_exception( 218 | self, 219 | logger_mock, decode 220 | ): 221 | auth = 'JWT ' + make_id_token(self.user.username) 222 | decode.side_effect = DecodeError, BadSignatureError 223 | 224 | resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) 225 | 226 | self.assertEqual(resp.status_code, 401) 227 | logger_mock.exception.assert_called_once_with( 228 | 'Invalid Authorization header. JWT Signature verification failed.') 229 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | from random import random 2 | from unittest import TestCase 3 | 4 | from oidc_auth.settings import api_settings 5 | from oidc_auth.util import cache 6 | 7 | try: 8 | from unittest.mock import patch, Mock, ANY 9 | except ImportError: 10 | from mock import patch, Mock, ANY 11 | 12 | 13 | class TestCacheDecorator(TestCase): 14 | @cache(1) 15 | def mymethod(self, *args): 16 | return random() 17 | 18 | @cache(1) 19 | def failing(self): 20 | raise RuntimeError() 21 | 22 | @cache(0) 23 | def notcached(self): 24 | return random() 25 | 26 | @cache(1) 27 | def return_none(self): 28 | return None 29 | 30 | def test_that_result_of_method_is_memoized(self): 31 | x = self.mymethod('a') 32 | y = self.mymethod('b') 33 | self.assertEqual(x, self.mymethod('a')) 34 | self.assertEqual(y, self.mymethod('b')) 35 | self.assertNotEqual(x, y) 36 | 37 | def test_that_exceptions_are_raised(self): 38 | with self.assertRaises(RuntimeError): 39 | self.failing() 40 | 41 | def test_that_cache_is_disabled_with_low_ttl(self): 42 | x = self.notcached() 43 | # This will fail sometimes when the RNG returns two equal numbers... 44 | self.assertNotEqual(x, self.notcached()) 45 | 46 | def test_that_cache_can_store_None(self): 47 | self.assertIsNone(self.return_none()) 48 | self.assertIsNone(self.return_none()) 49 | 50 | @patch('oidc_auth.util.caches') 51 | def test_uses_django_cache_uncached(self, caches): 52 | caches['default'].get.return_value = None 53 | self.mymethod() 54 | caches['default'].get.assert_called_with('oidc_auth.mymethod', version=1) 55 | caches['default'].set.assert_called_with('oidc_auth.mymethod', ANY, timeout=1, version=1) 56 | 57 | @patch('oidc_auth.util.caches') 58 | def test_uses_django_cache_cached(self, caches): 59 | return_value = random() 60 | caches['default'].get.return_value = return_value 61 | self.assertEqual(return_value, self.mymethod()) 62 | caches['default'].get.assert_called_with('oidc_auth.mymethod', version=1) 63 | self.assertFalse(caches['default'].set.called) 64 | 65 | @patch.object(api_settings, 'OIDC_CACHE_NAME', 'other') 66 | def test_respects_cache_name(self): 67 | caches = { 68 | 'default': Mock(), 69 | 'other': Mock(), 70 | } 71 | with patch('oidc_auth.util.caches', caches): 72 | self.mymethod() 73 | self.assertTrue(caches['other'].get.called) 74 | self.assertFalse(caches['default'].get.called) 75 | 76 | @patch.object(api_settings, 'OIDC_CACHE_PREFIX', 'some-other-prefix.') 77 | @patch('oidc_auth.util.caches') 78 | def test_respects_cache_prefix(self, caches): 79 | caches['default'].get.return_value = None 80 | self.mymethod() 81 | caches['default'].get.assert_called_once_with('some-other-prefix.mymethod', version=1) 82 | caches['default'].set.assert_called_once_with( 83 | 'some-other-prefix.mymethod', 84 | ANY, 85 | timeout=1, 86 | version=1 87 | ) 88 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | {py37,py38,py39}-django22-drf{311,312,313} 4 | {py37,py38,py39,py310}-django32-drf{311,312,313} 5 | {py38,py39,py310}-django40-drf{313} 6 | 7 | [gh-actions] 8 | python = 9 | 3.7: py37 10 | 3.8: py38 11 | 3.9: py39 12 | 3.10: py310 13 | 14 | [testenv] 15 | commands = 16 | django-admin test 17 | setenv = 18 | PYTHONDONTWRITEBYTECODE=1 19 | DJANGO_SETTINGS_MODULE=tests.settings 20 | PYTHONPATH={toxinidir} 21 | deps = 22 | django22: Django==2.2.* 23 | django32: Django==3.2.* 24 | django40: Django==4.0.* 25 | drf311: djangorestframework==3.11.* 26 | drf312: djangorestframework==3.12.* 27 | drf313: djangorestframework==3.13.* 28 | --------------------------------------------------------------------------------