├── .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 |
--------------------------------------------------------------------------------