diff --git a/.gitignore b/.gitignore index 6d9b88f..8d7a84f 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,6 @@ target/ # IDEA .idea/ + +# VSCode +.vscode/ diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..9d235b3 --- /dev/null +++ b/Pipfile @@ -0,0 +1,18 @@ +[[source]] +name = "pypi" +url = "https://pypi.org/simple" +verify_ssl = true + +[dev-packages] +autopep8 = "*" +flake8 = "*" +tox = "*" + +[packages] +django = "*" +djangorestframework = "*" +authlib = "*" +requests = "*" + +[requires] +python_version = "3.8" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..d827dda --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,314 @@ +{ + "_meta": { + "hash": { + "sha256": "ffab26b98f6d64d91ff47685d21d0eb8599a8d78b75b3cb09039ec6c75514627" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.8" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "asgiref": { + "hashes": [ + "sha256:7e51911ee147dd685c3c8b805c0ad0cb58d360987b56953878f8c06d2d1c6f1a", + "sha256:9fc6fb5d39b8af147ba40765234fa822b39818b12cc80b35ad9b0cef3a476aed" + ], + "markers": "python_version >= '3.5'", + "version": "==3.2.10" + }, + "authlib": { + "hashes": [ + "sha256:7b6b89287ce88a13ca35fd0de9841d17316ddb553661d044d8d576c0a77ee316", + "sha256:f107574d718764b9a88528e1ce29de439dedea7307400adc4fefe960ec78d1f2" + ], + "index": "pypi", + "version": "==0.15" + }, + "certifi": { + "hashes": [ + "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3", + "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41" + ], + "version": "==2020.6.20" + }, + "cffi": { + "hashes": [ + "sha256:005f2bfe11b6745d726dbb07ace4d53f057de66e336ff92d61b8c7e9c8f4777d", + "sha256:09e96138280241bd355cd585148dec04dbbedb4f46128f340d696eaafc82dd7b", + "sha256:0b1ad452cc824665ddc682400b62c9e4f5b64736a2ba99110712fdee5f2505c4", + "sha256:0ef488305fdce2580c8b2708f22d7785ae222d9825d3094ab073e22e93dfe51f", + "sha256:15f351bed09897fbda218e4db5a3d5c06328862f6198d4fb385f3e14e19decb3", + "sha256:22399ff4870fb4c7ef19fff6eeb20a8bbf15571913c181c78cb361024d574579", + "sha256:23e5d2040367322824605bc29ae8ee9175200b92cb5483ac7d466927a9b3d537", + "sha256:2791f68edc5749024b4722500e86303a10d342527e1e3bcac47f35fbd25b764e", + "sha256:2f9674623ca39c9ebe38afa3da402e9326c245f0f5ceff0623dccdac15023e05", + "sha256:3363e77a6176afb8823b6e06db78c46dbc4c7813b00a41300a4873b6ba63b171", + "sha256:33c6cdc071ba5cd6d96769c8969a0531be2d08c2628a0143a10a7dcffa9719ca", + "sha256:3b8eaf915ddc0709779889c472e553f0d3e8b7bdf62dab764c8921b09bf94522", + "sha256:3cb3e1b9ec43256c4e0f8d2837267a70b0e1ca8c4f456685508ae6106b1f504c", + "sha256:3eeeb0405fd145e714f7633a5173318bd88d8bbfc3dd0a5751f8c4f70ae629bc", + "sha256:44f60519595eaca110f248e5017363d751b12782a6f2bd6a7041cba275215f5d", + "sha256:4d7c26bfc1ea9f92084a1d75e11999e97b62d63128bcc90c3624d07813c52808", + "sha256:529c4ed2e10437c205f38f3691a68be66c39197d01062618c55f74294a4a4828", + "sha256:6642f15ad963b5092d65aed022d033c77763515fdc07095208f15d3563003869", + "sha256:85ba797e1de5b48aa5a8427b6ba62cf69607c18c5d4eb747604b7302f1ec382d", + "sha256:8f0f1e499e4000c4c347a124fa6a27d37608ced4fe9f7d45070563b7c4c370c9", + "sha256:a624fae282e81ad2e4871bdb767e2c914d0539708c0f078b5b355258293c98b0", + "sha256:b0358e6fefc74a16f745afa366acc89f979040e0cbc4eec55ab26ad1f6a9bfbc", + "sha256:bbd2f4dfee1079f76943767fce837ade3087b578aeb9f69aec7857d5bf25db15", + "sha256:bf39a9e19ce7298f1bd6a9758fa99707e9e5b1ebe5e90f2c3913a47bc548747c", + "sha256:c11579638288e53fc94ad60022ff1b67865363e730ee41ad5e6f0a17188b327a", + "sha256:c150eaa3dadbb2b5339675b88d4573c1be3cb6f2c33a6c83387e10cc0bf05bd3", + "sha256:c53af463f4a40de78c58b8b2710ade243c81cbca641e34debf3396a9640d6ec1", + "sha256:cb763ceceae04803adcc4e2d80d611ef201c73da32d8f2722e9d0ab0c7f10768", + "sha256:cc75f58cdaf043fe6a7a6c04b3b5a0e694c6a9e24050967747251fb80d7bce0d", + "sha256:d80998ed59176e8cba74028762fbd9b9153b9afc71ea118e63bbf5d4d0f9552b", + "sha256:de31b5164d44ef4943db155b3e8e17929707cac1e5bd2f363e67a56e3af4af6e", + "sha256:e66399cf0fc07de4dce4f588fc25bfe84a6d1285cc544e67987d22663393926d", + "sha256:f0620511387790860b249b9241c2f13c3a80e21a73e0b861a2df24e9d6f56730", + "sha256:f4eae045e6ab2bb54ca279733fe4eb85f1effda392666308250714e01907f394", + "sha256:f92cdecb618e5fa4658aeb97d5eb3d2f47aa94ac6477c6daf0f306c5a3b9e6b1", + "sha256:f92f789e4f9241cd262ad7a555ca2c648a98178a953af117ef7fad46aa1d5591" + ], + "version": "==1.14.3" + }, + "chardet": { + "hashes": [ + "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", + "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" + ], + "version": "==3.0.4" + }, + "cryptography": { + "hashes": [ + "sha256:21b47c59fcb1c36f1113f3709d37935368e34815ea1d7073862e92f810dc7499", + "sha256:451cdf60be4dafb6a3b78802006a020e6cd709c22d240f94f7a0696240a17154", + "sha256:4549b137d8cbe3c2eadfa56c0c858b78acbeff956bd461e40000b2164d9167c6", + "sha256:48ee615a779ffa749d7d50c291761dc921d93d7cf203dca2db663b4f193f0e49", + "sha256:559d622aef2a2dff98a892eef321433ba5bc55b2485220a8ca289c1ecc2bd54f", + "sha256:5d52c72449bb02dd45a773a203196e6d4fae34e158769c896012401f33064396", + "sha256:65beb15e7f9c16e15934569d29fb4def74ea1469d8781f6b3507ab896d6d8719", + "sha256:680da076cad81cdf5ffcac50c477b6790be81768d30f9da9e01960c4b18a66db", + "sha256:762bc5a0df03c51ee3f09c621e1cee64e3a079a2b5020de82f1613873d79ee70", + "sha256:89aceb31cd5f9fc2449fe8cf3810797ca52b65f1489002d58fe190bfb265c536", + "sha256:983c0c3de4cb9fcba68fd3f45ed846eb86a2a8b8d8bc5bb18364c4d00b3c61fe", + "sha256:99d4984aabd4c7182050bca76176ce2dbc9fa9748afe583a7865c12954d714ba", + "sha256:9d9fc6a16357965d282dd4ab6531013935425d0dc4950df2e0cf2a1b1ac1017d", + "sha256:a7597ffc67987b37b12e09c029bd1dc43965f75d328076ae85721b84046e9ca7", + "sha256:ab010e461bb6b444eaf7f8c813bb716be2d78ab786103f9608ffd37a4bd7d490", + "sha256:b12e715c10a13ca1bd27fbceed9adc8c5ff640f8e1f7ea76416352de703523c8", + "sha256:b2bded09c578d19e08bd2c5bb8fed7f103e089752c9cf7ca7ca7de522326e921", + "sha256:b372026ebf32fe2523159f27d9f0e9f485092e43b00a5adacf732192a70ba118", + "sha256:cb179acdd4ae1e4a5a160d80b87841b3d0e0be84af46c7bb2cd7ece57a39c4ba", + "sha256:e97a3b627e3cb63c415a16245d6cef2139cca18bb1183d1b9375a1c14e83f3b3", + "sha256:f0e099fc4cc697450c3dd4031791559692dd941a95254cb9aeded66a7aa8b9bc", + "sha256:f99317a0fa2e49917689b8cf977510addcfaaab769b3f899b9c481bbd76730c2" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==3.1.1" + }, + "django": { + "hashes": [ + "sha256:a2127ad0150ec6966655bedf15dbbff9697cc86d61653db2da1afa506c0b04cc", + "sha256:c93c28ccf1d094cbd00d860e83128a39e45d2c571d3b54361713aaaf9a94cac4" + ], + "index": "pypi", + "version": "==3.1.2" + }, + "djangorestframework": { + "hashes": [ + "sha256:5c5071fcbad6dce16f566d492015c829ddb0df42965d488b878594aabc3aed21", + "sha256:d54452aedebb4b650254ca092f9f4f5df947cb1de6ab245d817b08b4f4156249" + ], + "index": "pypi", + "version": "==3.12.1" + }, + "idna": { + "hashes": [ + "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", + "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.10" + }, + "pycparser": { + "hashes": [ + "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", + "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.20" + }, + "pytz": { + "hashes": [ + "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed", + "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048" + ], + "version": "==2020.1" + }, + "requests": { + "hashes": [ + "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b", + "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898" + ], + "index": "pypi", + "version": "==2.24.0" + }, + "six": { + "hashes": [ + "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", + "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.15.0" + }, + "sqlparse": { + "hashes": [ + "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0", + "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8" + ], + "markers": "python_version >= '3.5'", + "version": "==0.4.1" + }, + "urllib3": { + "hashes": [ + "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a", + "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", + "version": "==1.25.10" + } + }, + "develop": { + "appdirs": { + "hashes": [ + "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", + "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" + ], + "version": "==1.4.4" + }, + "autopep8": { + "hashes": [ + "sha256:d21d3901cb0da6ebd1e83fc9b0dfbde8b46afc2ede4fe32fbda0c7c6118ca094" + ], + "index": "pypi", + "version": "==1.5.4" + }, + "distlib": { + "hashes": [ + "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb", + "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1" + ], + "version": "==0.3.1" + }, + "filelock": { + "hashes": [ + "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59", + "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836" + ], + "version": "==3.0.12" + }, + "flake8": { + "hashes": [ + "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839", + "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b" + ], + "index": "pypi", + "version": "==3.8.4" + }, + "mccabe": { + "hashes": [ + "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", + "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" + ], + "version": "==0.6.1" + }, + "packaging": { + "hashes": [ + "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", + "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==20.4" + }, + "pluggy": { + "hashes": [ + "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", + "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==0.13.1" + }, + "py": { + "hashes": [ + "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", + "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.9.0" + }, + "pycodestyle": { + "hashes": [ + "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", + "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.6.0" + }, + "pyflakes": { + "hashes": [ + "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92", + "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.2.0" + }, + "pyparsing": { + "hashes": [ + "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", + "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" + ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.4.7" + }, + "six": { + "hashes": [ + "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", + "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.15.0" + }, + "toml": { + "hashes": [ + "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f", + "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88" + ], + "version": "==0.10.1" + }, + "tox": { + "hashes": [ + "sha256:42ce19ce5dc2f6d6b1fdc5666c476e1f1e2897359b47e0aa3a5b774f335d57c2", + "sha256:4321052bfe28f9d85082341ca8e233e3ea901fdd14dab8a5d3fbd810269fbaf6" + ], + "index": "pypi", + "version": "==3.20.1" + }, + "virtualenv": { + "hashes": [ + "sha256:35ecdeb58cfc2147bb0706f7cdef69a8f34f1b81b6d49568174e277932908b8f", + "sha256:a5e0d253fe138097c6559c906c528647254f437d1019af9d5a477b09bfa7300f" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==20.0.33" + } + } +} diff --git a/README.md b/README.md index b6b3e14..a5712d9 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,16 @@ OIDC_AUTH = { # at /.well-known/openid-configuration 'OIDC_ENDPOINT': 'https://accounts.google.com', - # Accepted audiences the ID Tokens can be issued to - 'OIDC_AUDIENCES': ('myapp',), + # The Claims Options can now be defined by a static string. + # ref: https://docs.authlib.org/en/latest/jose/jwt.html#jwt-payload-claims-validation + # The old OIDC_AUDIENCES option is removed in favor of this new option. + # `aud` is only required, when you set it as an essential claim. + 'OIDC_CLAIMS_OPTIONS': { + 'aud': { + 'values': ['myapp'], + 'essential': True, + } + }, # (Optional) Function that resolves id_token into user. # This function receives a request and an id_token dict and expects to diff --git a/oidc_auth/authentication.py b/oidc_auth/authentication.py index 2b97f86..964ad50 100644 --- a/oidc_auth/authentication.py +++ b/oidc_auth/authentication.py @@ -1,23 +1,27 @@ -from calendar import timegm -import datetime -from django.contrib.auth import get_user_model -from django.utils.encoding import smart_text -from django.utils.functional import cached_property -from jwkest import JWKESTException -from jwkest.jwk import KEYS -from jwkest.jws import JWS import logging +import time + import requests +from authlib.jose import JsonWebKey, jwt +from authlib.jose.errors import (BadSignatureError, DecodeError, + ExpiredTokenError, JoseError) +from authlib.oidc.core.claims import IDToken +from authlib.oidc.discovery import get_well_known_url +from django.contrib.auth import get_user_model +from django.utils.encoding import smart_str +from django.utils.functional import cached_property +from django.utils.translation import ugettext as _ from requests import request from requests.exceptions import HTTPError -from rest_framework.authentication import BaseAuthentication, get_authorization_header +from rest_framework.authentication import (BaseAuthentication, + get_authorization_header) from rest_framework.exceptions import AuthenticationFailed -import six -from .util import cache + from .settings import api_settings -from django.utils.translation import ugettext as _ +from .util import cache -logger = logging.Logger(__name__) +logging.basicConfig() +logger = logging.getLogger(__name__) def get_user_by_id(request, id_token): @@ -30,11 +34,31 @@ def get_user_by_id(request, id_token): return user +class DRFIDToken(IDToken): + + def validate_exp(self, now, leeway): + super(DRFIDToken, self).validate_exp(now, leeway) + if now > self['exp']: + msg = _('Invalid Authorization header. JWT has expired.') + raise AuthenticationFailed(msg) + + def validate_iat(self, now, leeway): + super(DRFIDToken, self).validate_iat(now, leeway) + if self['iat'] < leeway: + msg = _('Invalid Authorization header. JWT too old.') + raise AuthenticationFailed(msg) + + class BaseOidcAuthentication(BaseAuthentication): @property @cache(ttl=api_settings.OIDC_BEARER_TOKEN_EXPIRATION_TIME) def oidc_config(self): - return requests.get(api_settings.OIDC_ENDPOINT + '/.well-known/openid-configuration').json() + return requests.get( + get_well_known_url( + api_settings.OIDC_ENDPOINT, + external=True + ) + ).json() class BearerTokenAuthentication(BaseOidcAuthentication): @@ -58,23 +82,28 @@ def authenticate(self, request): def get_bearer_token(self, request): auth = get_authorization_header(request).split() auth_header_prefix = api_settings.BEARER_AUTH_HEADER_PREFIX.lower() - - if not auth or smart_text(auth[0].lower()) != auth_header_prefix: + if not auth or smart_str(auth[0].lower()) != auth_header_prefix: return None if len(auth) == 1: msg = _('Invalid Authorization header. No credentials provided') raise AuthenticationFailed(msg) elif len(auth) > 2: - msg = _('Invalid Authorization header. Credentials string should not contain spaces.') + msg = _( + 'Invalid Authorization header. Credentials string should not contain spaces.') raise AuthenticationFailed(msg) return auth[1] @cache(ttl=api_settings.OIDC_BEARER_TOKEN_EXPIRATION_TIME) def get_userinfo(self, token): - response = requests.get(self.oidc_config['userinfo_endpoint'], - headers={'Authorization': 'Bearer {0}'.format(token.decode('ascii'))}) + response = requests.get( + self.oidc_config['userinfo_endpoint'], + headers={'Authorization': 'Bearer {0}'.format( + token.decode('ascii') + ) + } + ) response.raise_for_status() return response.json() @@ -85,11 +114,22 @@ class JSONWebTokenAuthentication(BaseOidcAuthentication): www_authenticate_realm = 'api' + @property + def claims_options(self): + _claims_options = { + 'iss': { + 'essential': True, + 'values': [self.issuer] + } + } + for key, value in api_settings.OIDC_CLAIMS_OPTIONS.items(): + _claims_options[key] = value + return _claims_options + def authenticate(self, request): jwt_value = self.get_jwt_value(request) if jwt_value is None: return None - payload = self.decode_jwt(jwt_value) self.validate_claims(payload) @@ -101,71 +141,64 @@ def get_jwt_value(self, request): auth = get_authorization_header(request).split() auth_header_prefix = api_settings.JWT_AUTH_HEADER_PREFIX.lower() - if not auth or smart_text(auth[0].lower()) != auth_header_prefix: + if not auth or smart_str(auth[0].lower()) != auth_header_prefix: return None if len(auth) == 1: msg = _('Invalid Authorization header. No credentials provided') raise AuthenticationFailed(msg) elif len(auth) > 2: - msg = _('Invalid Authorization header. Credentials string should not contain spaces.') + msg = _( + 'Invalid Authorization header. Credentials string should not contain spaces.') raise AuthenticationFailed(msg) return auth[1] def jwks(self): - keys = KEYS() - keys.load_jwks(self.jwks_data()) - return keys + return JsonWebKey.import_key_set(self.jwks_data()) @cache(ttl=api_settings.OIDC_JWKS_EXPIRATION_TIME) def jwks_data(self): r = request("GET", self.oidc_config['jwks_uri'], allow_redirects=True) r.raise_for_status() - return r.text + return r.json() @cached_property def issuer(self): return self.oidc_config['issuer'] def decode_jwt(self, jwt_value): - keys = self.jwks() try: - id_token = JWS().verify_compact(jwt_value, keys=keys) - except (JWKESTException, ValueError): - msg = _('Invalid Authorization header. JWT Signature verification failed.') + id_token = jwt.decode( + jwt_value.decode('ascii'), + self.jwks(), + claims_cls=DRFIDToken, + claims_options=self.claims_options + ) + except (BadSignatureError, DecodeError): + msg = _( + 'Invalid Authorization header. JWT Signature verification failed.') logger.exception(msg) raise AuthenticationFailed(msg) + except AssertionError: + msg = _( + 'Invalid Authorization header. Please provide base64 encoded ID Token' + ) + raise AuthenticationFailed(msg) return id_token - def get_audiences(self, id_token): - return api_settings.OIDC_AUDIENCES - def validate_claims(self, id_token): - if isinstance(id_token.get('aud'), six.string_types): - # Support for multiple audiences - id_token['aud'] = [id_token['aud']] - - if id_token.get('iss') != self.issuer: - msg = _('Invalid Authorization header. Invalid JWT issuer.') - raise AuthenticationFailed(msg) - if not any(aud in self.get_audiences(id_token) for aud in id_token.get('aud', [])): - msg = _('Invalid Authorization header. Invalid JWT audience.') - raise AuthenticationFailed(msg) - if len(id_token['aud']) > 1 and 'azp' not in id_token: - msg = _('Invalid Authorization header. Missing JWT authorized party.') - raise AuthenticationFailed(msg) - - utc_timestamp = timegm(datetime.datetime.utcnow().utctimetuple()) - if utc_timestamp > id_token.get('exp', 0): + try: + id_token.validate( + now=int(time.time()), + leeway=int(time.time()-api_settings.OIDC_LEEWAY) + ) + except ExpiredTokenError: msg = _('Invalid Authorization header. JWT has expired.') raise AuthenticationFailed(msg) - if 'nbf' in id_token and utc_timestamp < id_token['nbf']: - msg = _('Invalid Authorization header. JWT not yet valid.') - raise AuthenticationFailed(msg) - if utc_timestamp > id_token.get('iat', 0) + api_settings.OIDC_LEEWAY: - msg = _('Invalid Authorization header. JWT too old.') + except JoseError as e: + msg = _(str(type(e)) + str(e)) raise AuthenticationFailed(msg) def authenticate_header(self, request): diff --git a/oidc_auth/settings.py b/oidc_auth/settings.py index 284ad6c..a8f134a 100644 --- a/oidc_auth/settings.py +++ b/oidc_auth/settings.py @@ -1,13 +1,21 @@ from django.conf import settings from rest_framework.settings import APISettings - USER_SETTINGS = getattr(settings, 'OIDC_AUTH', None) DEFAULTS = { 'OIDC_ENDPOINT': None, - 'OIDC_ENDPOINTS': {}, - 'OIDC_AUDIENCES': None, + + # Currently unimplemented + 'OIDC_ENDPOINTS': [], + + # The Claims Options can now be defined by a static string. + # ref: https://docs.authlib.org/en/latest/jose/jwt.html#jwt-payload-claims-validation + 'OIDC_CLAIMS_OPTIONS': { + 'aud': { + 'essential': True, + } + }, # Number of seconds in the past valid tokens can be issued 'OIDC_LEEWAY': 600, @@ -27,6 +35,8 @@ # The Django cache to use 'OIDC_CACHE_NAME': 'default', 'OIDC_CACHE_PREFIX': 'oidc_auth.' + + } # List of settings that may be in string import notation. diff --git a/oidc_auth/util.py b/oidc_auth/util.py index 75761d0..7e397be 100644 --- a/oidc_auth/util.py +++ b/oidc_auth/util.py @@ -1,14 +1,16 @@ import functools from django.core.cache import caches + from .settings import api_settings class cache(object): """ Cache decorator that memoizes the return value of a method for some time. - Increment the cache_version everytime your method's implementation changes in such a way that it returns values - that are not backwards compatible. For more information, see the Django cache documentation: + Increment the cache_version everytime your method's implementation changes + in such a way that it returns values that are not backwards compatible. + For more information, see the Django cache documentation: https://docs.djangoproject.com/en/2.2/topics/cache/#cache-versioning """ diff --git a/setup.cfg b/setup.cfg index 5e40900..bc26005 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,4 @@ [wheel] universal = 1 +[flake8] +max-line-length=100 diff --git a/setup.py b/setup.py index aca8283..9e65c14 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name='drf-oidc-auth', - version='0.10.0', + version='1.0.0', packages=['oidc_auth'], url='https://github.com/ByteInternet/drf-oidc-auth', license='MIT', @@ -10,9 +10,11 @@ author_email='support@byte.nl', description='OpenID Connect authentication for Django Rest Framework', install_requires=[ - 'pyjwkest>=1.0.3', + 'authlib>=0.15.0', + 'cryptography>=2.6', 'django>=1.8.0', 'djangorestframework>=3.0.0', + 'requests>=2.20.0' ], classifiers=[ 'Development Status :: 4 - Beta', @@ -26,6 +28,10 @@ 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', 'Topic :: Internet', 'Topic :: Internet :: WWW/HTTP', 'Topic :: Security', diff --git a/tests/settings.py b/tests/settings.py index 8f768da..f51b424 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -1,4 +1,4 @@ -SECRET_KEY='secret' +SECRET_KEY = 'secret' DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', @@ -12,5 +12,10 @@ ROOT_URLCONF = 'tests.test_authentication' OIDC_AUTH = { 'OIDC_ENDPOINT': 'http://example.com', - 'OIDC_AUDIENCES': ('you',), + 'OIDC_CLAIMS_OPTIONS': { + 'aud': { + 'values': ['you'], + 'essential': True, + } + }, } diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 3f8a81a..ed347bc 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -1,19 +1,24 @@ -from django.contrib.auth.models import User import json +import sys -from jwkest import long_to_base64, JWKESTException -from rest_framework.permissions import IsAuthenticated from django.conf.urls import url +from django.contrib.auth.models import User from django.http import HttpResponse from django.test import TestCase -from jwkest.jwk import RSAKey, KEYS -from jwkest.jws import JWS +from requests import Response +from rest_framework.permissions import IsAuthenticated from rest_framework.views import APIView -from requests import Response, HTTPError, ConnectionError -from oidc_auth.authentication import JSONWebTokenAuthentication, BearerTokenAuthentication -import sys + +from authlib.jose import JsonWebToken, KeySet, RSAKey +from authlib.jose.errors import BadSignatureError, DecodeError +from oidc_auth.authentication import (BearerTokenAuthentication, + JSONWebTokenAuthentication) + if sys.version_info > (3,): long = int +else: + class ConnectionError(OSError): + pass try: from unittest.mock import patch, Mock except ImportError: @@ -22,7 +27,10 @@ class MockView(APIView): permission_classes = (IsAuthenticated,) - authentication_classes = (JSONWebTokenAuthentication, BearerTokenAuthentication) + authentication_classes = ( + JSONWebTokenAuthentication, + BearerTokenAuthentication + ) def get(self, request): return HttpResponse('a') @@ -32,16 +40,14 @@ def get(self, request): url(r'^test/$', MockView.as_view(), name="testview") ] -key = RSAKey(kid="test", - kty="RSA", - e=long_to_base64(long(65537)), - n=long_to_base64(long(103144733181541730170695212353035735911272360475451101847332641719504193145911782103718552703497383385072400068398348471608551845979550140132066577502098324638900101678499876506366406838561711807168917151266210861310839976066381600661109647310812646802675105044570916072792610952531033569123889433857109695663)), - d=long_to_base64(long(87474011172773995802176478974956531454728135178991596207863469898989014679490621318105454312226445649668492543167679449044101982079487873850500638991205330610459744732712633893362912169260215247013564296846583369572335796121742404877695795618480142002129365141632060905382558309932032446524457731175746076993))) +key = RSAKey.generate_key(is_private=True) def make_jwt(payload): - jws = JWS(payload, alg='RS256') - return jws.sign_compact([key]) + jwt = JsonWebToken(['RS256']) + jws = jwt.encode( + {'alg': 'RS256', 'kid': key.as_dict(add_kid=True).get('kid')}, payload, key=key) + return jws def make_id_token(sub, @@ -50,14 +56,16 @@ def make_id_token(sub, exp=999999999999, # tests will start failing in September 33658 iat=999999999999, **kwargs): - return make_jwt(dict( - iss=iss, - aud=aud, - exp=exp, - iat=iat, - sub=str(sub), - **kwargs - )) + return make_jwt( + dict( + iss=iss, + aud=aud, + exp=exp, + iat=iat, + sub=str(sub), + **kwargs + ) + ).decode('ascii') class FakeRequests(object): @@ -99,23 +107,30 @@ def setUp(self): "userinfo_endpoint": "http://example.com/userinfo"}) self.mock_get = self.patch('requests.get') self.mock_get.side_effect = self.responder.get - keys = KEYS() - keys.add({'key': key, 'kty': 'RSA', 'kid': key.kid}) - self.patch('oidc_auth.authentication.request', return_value=Mock(status_code=200, - text=keys.dump_jwks())) + keys = KeySet(keys=[key]) + self.patch( + 'oidc_auth.authentication.request', + return_value=Mock( + status_code=200, + json=keys.as_json + ) + ) class TestBearerAuthentication(AuthenticationTestCase): def test_using_valid_bearer_token(self): - self.responder.set_response('http://example.com/userinfo', {'sub': self.user.username}) + self.responder.set_response( + 'http://example.com/userinfo', {'sub': self.user.username}) auth = 'Bearer abcdefg' resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) self.assertEqual(resp.content.decode(), 'a') self.assertEqual(resp.status_code, 200) - self.mock_get.assert_called_with('http://example.com/userinfo', headers={'Authorization': auth}) + self.mock_get.assert_called_with( + 'http://example.com/userinfo', headers={'Authorization': auth}) def test_cache_of_valid_bearer_token(self): - self.responder.set_response('http://example.com/userinfo', {'sub': self.user.username}) + self.responder.set_response( + 'http://example.com/userinfo', {'sub': self.user.username}) auth = 'Bearer egergerg' resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) self.assertEqual(resp.status_code, 200) @@ -138,7 +153,8 @@ def test_cache_of_invalid_bearer_token(self): self.assertEqual(resp.status_code, 401) # Token becomes valid - self.responder.set_response('http://example.com/userinfo', {'sub': self.user.username}) + self.responder.set_response( + 'http://example.com/userinfo', {'sub': self.user.username}) resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) self.assertEqual(resp.status_code, 200) @@ -163,15 +179,15 @@ class TestJWTAuthentication(AuthenticationTestCase): def test_using_valid_jwt(self): auth = 'JWT ' + make_id_token(self.user.username) resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) - self.assertEqual(resp.content.decode(), 'a') self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.content.decode(), 'a') def test_without_jwt(self): resp = self.client.get('/test/') self.assertEqual(resp.status_code, 401) def test_with_invalid_jwt(self): - auth = 'JWT bla' + auth = 'JWT e30=' resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) self.assertEqual(resp.status_code, 401) @@ -191,7 +207,8 @@ def test_with_old_jwt(self): self.assertEqual(resp.status_code, 401) def test_with_invalid_issuer(self): - auth = 'JWT ' + make_id_token(self.user.username, iss='http://something.com') + auth = 'JWT ' + \ + make_id_token(self.user.username, iss='http://something.com') resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) self.assertEqual(resp.status_code, 401) @@ -213,10 +230,16 @@ def test_with_unknown_subject(self): def test_with_multiple_audiences(self): auth = 'JWT ' + make_id_token(self.user.username, aud=['you', 'me']) resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) + self.assertEqual(resp.status_code, 200) + + def test_with_invalid_multiple_audiences(self): + auth = 'JWT ' + make_id_token(self.user.username, aud=['we', 'me']) + resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) self.assertEqual(resp.status_code, 401) def test_with_multiple_audiences_and_authorized_party(self): - auth = 'JWT ' + make_id_token(self.user.username, aud=['you', 'me'], azp='you') + auth = 'JWT ' + \ + make_id_token(self.user.username, aud=['you', 'me'], azp='you') resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) self.assertEqual(resp.status_code, 200) @@ -225,13 +248,17 @@ def test_with_invalid_signature(self): resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth + 'x') self.assertEqual(resp.status_code, 401) - @patch('oidc_auth.authentication.JWS.verify_compact') + @patch('oidc_auth.authentication.jwt.decode') @patch('oidc_auth.authentication.logger') - def test_decode_jwt_logs_exception_message_when_verify_compact_throws_exception(self, logger_mock, verify_compact_mock): + def test_decode_jwt_logs_exception_message_when_decode_throws_exception( + self, + logger_mock, decode + ): auth = 'JWT ' + make_id_token(self.user.username) - verify_compact_mock.side_effect = JWKESTException + decode.side_effect = DecodeError, BadSignatureError resp = self.client.get('/test/', HTTP_AUTHORIZATION=auth) self.assertEqual(resp.status_code, 401) - logger_mock.exception.assert_called_once_with('Invalid Authorization header. JWT Signature verification failed.') + logger_mock.exception.assert_called_once_with( + 'Invalid Authorization header. JWT Signature verification failed.') diff --git a/tests/test_util.py b/tests/test_util.py index 411300d..30a8e15 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -79,4 +79,9 @@ def test_respects_cache_prefix(self, caches): caches['default'].get.return_value = None self.mymethod() caches['default'].get.assert_called_once_with('some-other-prefix.mymethod', version=1) - caches['default'].set.assert_called_once_with('some-other-prefix.mymethod', ANY, timeout=1, version=1) + caches['default'].set.assert_called_once_with( + 'some-other-prefix.mymethod', + ANY, + timeout=1, + version=1 + )