From 6199a9a17e07008924969c3a4b52492d73d621e7 Mon Sep 17 00:00:00 2001 From: Tuomas Suutari Date: Fri, 7 Jul 2017 21:58:05 +0300 Subject: [PATCH 01/26] Fix scope handling of token endpoint The token endpoint handled the scope parameter incorrectly for all of the three handled grant types: 1. For "authorization_code" grant type the scope parameter in the token request should not be respected but the scope should be taken from the authorization code. It was not totally ignored, but rather the scope parameter of the token request was used for the generated ID token. This had two consequences: * Spec conforming implementations of authorization code flow didn't get correct ID tokens, since they usually don't pass scope parameter with the token request. * It's possible to get a broader scope for the ID token than what is authorized by the user in the original authorization code request. 2. For "refresh_token" grant type the scope parameter in the token request should only allow narrowing down the scope. It wasn't narrowed, but rather the original auth code scope was used for the access token and the passed in scope parameter was used for the ID token (again allowing unauthorized scopes in the ID token). 3. For "password" grant type the scope parameter in the token request should be respected. The problem with this was that it wasn't properly splitted when passed to ID token creation. Fixes #186 --- oidc_provider/lib/endpoints/token.py | 20 +++- oidc_provider/tests/app/utils.py | 2 + oidc_provider/tests/test_token_endpoint.py | 115 ++++++++++++++++++--- 3 files changed, 116 insertions(+), 21 deletions(-) diff --git a/oidc_provider/lib/endpoints/token.py b/oidc_provider/lib/endpoints/token.py index ae1eb98..88734b7 100644 --- a/oidc_provider/lib/endpoints/token.py +++ b/oidc_provider/lib/endpoints/token.py @@ -162,6 +162,8 @@ class TokenEndpoint(object): return self.create_access_token_response_dic() def create_access_token_response_dic(self): + # See https://tools.ietf.org/html/rfc6749#section-4.3 + token = create_token( self.user, self.client, @@ -173,7 +175,7 @@ class TokenEndpoint(object): nonce='self.code.nonce', at_hash=token.at_hash, request=self.request, - scope=self.params['scope'], + scope=token.scope, ) token.id_token = id_token_dic @@ -188,6 +190,8 @@ class TokenEndpoint(object): } def create_code_response_dic(self): + # See https://tools.ietf.org/html/rfc6749#section-4.1 + token = create_token( user=self.code.user, client=self.code.client, @@ -200,7 +204,7 @@ class TokenEndpoint(object): nonce=self.code.nonce, at_hash=token.at_hash, request=self.request, - scope=self.params['scope'], + scope=token.scope, ) else: id_token_dic = {} @@ -223,10 +227,18 @@ class TokenEndpoint(object): return dic def create_refresh_response_dic(self): + # See https://tools.ietf.org/html/rfc6749#section-6 + + scope_param = self.params['scope'] + scope = (scope_param.split(' ') if scope_param else self.token.scope) + unauthorized_scopes = set(scope) - set(self.token.scope) + if unauthorized_scopes: + raise TokenError('invalid_scope') + token = create_token( user=self.token.user, client=self.token.client, - scope=self.token.scope) + scope=scope) # If the Token has an id_token it's an Authentication request. if self.token.id_token: @@ -236,7 +248,7 @@ class TokenEndpoint(object): nonce=None, at_hash=token.at_hash, request=self.request, - scope=self.params['scope'], + scope=token.scope, ) else: id_token_dic = {} diff --git a/oidc_provider/tests/app/utils.py b/oidc_provider/tests/app/utils.py index 8f8f72e..4f824ec 100644 --- a/oidc_provider/tests/app/utils.py +++ b/oidc_provider/tests/app/utils.py @@ -29,6 +29,8 @@ def create_fake_user(): user = User() user.username = 'johndoe' user.email = 'johndoe@example.com' + user.first_name = 'John' + user.last_name = 'Doe' user.set_password('1234') user.save() diff --git a/oidc_provider/tests/test_token_endpoint.py b/oidc_provider/tests/test_token_endpoint.py index 46e96e4..0760555 100644 --- a/oidc_provider/tests/test_token_endpoint.py +++ b/oidc_provider/tests/test_token_endpoint.py @@ -50,15 +50,18 @@ class TokenTestCase(TestCase): self.user = create_fake_user() self.client = create_fake_client(response_type='code') - def _password_grant_post_data(self): - return { + def _password_grant_post_data(self, scope=None): + result = { 'username': 'johndoe', 'password': '1234', 'grant_type': 'password', 'scope': 'openid email', } + if scope is not None: + result['scope'] = ' '.join(scope) + return result - def _auth_code_post_data(self, code): + def _auth_code_post_data(self, code, scope=None): """ All the data that will be POSTed to the Token Endpoint. """ @@ -70,10 +73,12 @@ class TokenTestCase(TestCase): 'code': code, 'state': uuid.uuid4().hex, } + if scope is not None: + post_data['scope'] = ' '.join(scope) return post_data - def _refresh_token_post_data(self, refresh_token): + def _refresh_token_post_data(self, refresh_token, scope=None): """ All the data that will be POSTed to the Token Endpoint. """ @@ -83,6 +88,8 @@ class TokenTestCase(TestCase): 'grant_type': 'refresh_token', 'refresh_token': refresh_token, } + if scope is not None: + post_data['scope'] = ' '.join(scope) return post_data @@ -103,14 +110,14 @@ class TokenTestCase(TestCase): return response - def _create_code(self): + def _create_code(self, scope=None): """ Generate a valid grant code. """ code = create_code( user=self.user, client=self.client, - scope=['openid', 'email'], + scope=(scope if scope else ['openid', 'email']), nonce=FAKE_NONCE, is_authentication=True) code.save() @@ -228,30 +235,41 @@ class TokenTestCase(TestCase): self.assertEqual(400, response.status_code) self.assertEqual('invalid_client', response_dict['error']) - @patch('oidc_provider.lib.utils.token.uuid') + def test_password_grant_full_response(self): + self.check_password_grant(scope=['openid', 'email']) + + def test_password_grant_scope(self): + self.check_password_grant(scope=['openid', 'profile']) + @override_settings(OIDC_TOKEN_EXPIRE=120, OIDC_GRANT_TYPE_PASSWORD_ENABLE=True) - def test_password_grant_full_response(self, mock_uuid): - test_hex = 'fake_token' - mock_uuid4 = Mock(spec=uuid.uuid4) - mock_uuid4.hex = test_hex - mock_uuid.uuid4.return_value = mock_uuid4 - + def check_password_grant(self, scope): response = self._post_request( - post_data=self._password_grant_post_data(), + post_data=self._password_grant_post_data(scope), extras=self._password_grant_auth_header() ) response_dict = json.loads(response.content.decode('utf-8')) id_token = JWS().verify_compact(response_dict['id_token'].encode('utf-8'), self._get_keys()) - self.assertEqual(response_dict['access_token'], 'fake_token') - self.assertEqual(response_dict['refresh_token'], 'fake_token') + token = Token.objects.get(user=self.user) + self.assertEqual(response_dict['access_token'], token.access_token) + self.assertEqual(response_dict['refresh_token'], token.refresh_token) self.assertEqual(response_dict['expires_in'], 120) self.assertEqual(response_dict['token_type'], 'bearer') self.assertEqual(id_token['sub'], str(self.user.id)) self.assertEqual(id_token['aud'], self.client.client_id) + # Check the scope is honored by checking the claims in the userinfo + userinfo_response = self._get_userinfo(response_dict['access_token']) + userinfo = json.loads(userinfo_response.content.decode('utf-8')) + + for (scope_param, claim) in [('email', 'email'), ('profile', 'name')]: + if scope_param in scope: + self.assertIn(claim, userinfo) + else: + self.assertNotIn(claim, userinfo) + @override_settings(OIDC_TOKEN_EXPIRE=720) def test_authorization_code(self): """ @@ -277,16 +295,64 @@ class TokenTestCase(TestCase): self.assertEqual(id_token['sub'], str(self.user.id)) self.assertEqual(id_token['aud'], self.client.client_id) + @override_settings(OIDC_TOKEN_EXPIRE=720) + def test_scope_is_ignored_for_auth_code(self): + """ + Scope is ignored for token respones to auth code grant type. + """ + SIGKEYS = self._get_keys() + for code_scope in [['openid'], ['openid', 'email']]: + code = self._create_code(code_scope) + + post_data = self._auth_code_post_data( + code=code.code, scope=['openid', 'profile']) + + response = self._post_request(post_data) + response_dic = json.loads(response.content.decode('utf-8')) + + self.assertEqual(response.status_code, 200) + + id_token = JWS().verify_compact(response_dic['id_token'].encode('utf-8'), SIGKEYS) + + if 'email' in code_scope: + self.assertIn('email', id_token) + else: + self.assertNotIn('email', id_token) + def test_refresh_token(self): """ A request to the Token Endpoint can also use a Refresh Token by using the grant_type value refresh_token, as described in Section 6 of OAuth 2.0 [RFC6749]. """ + self.do_refresh_token_check() + + def test_refresh_token_invalid_scope(self): + """ + Extending scope in refresh token is not allowed. + + Try to get a refresh token with "profile" in the scope even + though the original authorized scope in the authorization code + request is only ['openid', 'email']. + """ + self.do_refresh_token_check(scope=['openid', 'profile']) + + def test_refresh_token_narrowed_scope(self): + """ + Narrowing scope in refresh token is allowed. + + Try to get a refresh token with just "openid" in the scope even + though the original authorized scope in the authorization code + request is ['openid', 'email']. + """ + self.do_refresh_token_check(scope=['openid']) + + def do_refresh_token_check(self, scope=None): SIGKEYS = self._get_keys() # Retrieve refresh token code = self._create_code() + self.assertEqual(code.scope, ['openid', 'email']) post_data = self._auth_code_post_data(code=code.code) start_time = time.time() with patch('oidc_provider.lib.utils.token.time.time') as time_func: @@ -297,14 +363,29 @@ class TokenTestCase(TestCase): id_token1 = JWS().verify_compact(response_dic1['id_token'].encode('utf-8'), SIGKEYS) # Use refresh token to obtain new token - post_data = self._refresh_token_post_data(response_dic1['refresh_token']) + post_data = self._refresh_token_post_data( + response_dic1['refresh_token'], scope) with patch('oidc_provider.lib.utils.token.time.time') as time_func: time_func.return_value = start_time + 600 response = self._post_request(post_data) response_dic2 = json.loads(response.content.decode('utf-8')) + + if scope and set(scope) - set(code.scope): # too broad scope + self.assertEqual(response.status_code, 400) # Bad Request + self.assertIn('error', response_dic2) + self.assertEqual(response_dic2['error'], 'invalid_scope') + return # No more checks + id_token2 = JWS().verify_compact(response_dic2['id_token'].encode('utf-8'), SIGKEYS) + if scope and 'email' not in scope: # narrowed scope The auth + # The auth code request had email in scope, so it should be + # in the first id token + self.assertIn('email', id_token1) + # but the refresh request had no email in scope + self.assertNotIn('email', id_token2, 'email was not requested') + self.assertNotEqual(response_dic1['id_token'], response_dic2['id_token']) self.assertNotEqual(response_dic1['access_token'], response_dic2['access_token']) self.assertNotEqual(response_dic1['refresh_token'], response_dic2['refresh_token']) From eb682f23ff9528a26dc5d424d166e068ef67796e Mon Sep 17 00:00:00 2001 From: Tuomas Suutari Date: Fri, 7 Jul 2017 22:55:18 +0300 Subject: [PATCH 02/26] Pass scope to OIDC_IDTOKEN_PROCESSING_HOOK The ID token processing hook might want to add claims to the ID token conditionally based on the scope parameter. Therefore it would be very useful to provide the scope parameter to the processing hook. --- oidc_provider/lib/utils/common.py | 5 ++++- oidc_provider/lib/utils/token.py | 13 +++++++------ oidc_provider/tests/app/utils.py | 12 ++++++++++-- oidc_provider/tests/test_token_endpoint.py | 20 ++++++++++++++++++++ 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/oidc_provider/lib/utils/common.py b/oidc_provider/lib/utils/common.py index c4778bd..9ef225d 100644 --- a/oidc_provider/lib/utils/common.py +++ b/oidc_provider/lib/utils/common.py @@ -114,7 +114,7 @@ def default_after_end_session_hook(request, id_token=None, post_logout_redirect_ return None -def default_idtoken_processing_hook(id_token, user): +def default_idtoken_processing_hook(id_token, user, scope=None): """ Hook to perform some additional actions ti `id_token` dictionary just before serialization. @@ -124,6 +124,9 @@ def default_idtoken_processing_hook(id_token, user): :param user: user for whom id_token is generated :type user: User + :param scope: scope for the token + :type scope: list[str]|None + :return: custom modified dictionary of values for `id_token` :rtype dict """ diff --git a/oidc_provider/lib/utils/token.py b/oidc_provider/lib/utils/token.py index 3f0d2e9..91bf459 100644 --- a/oidc_provider/lib/utils/token.py +++ b/oidc_provider/lib/utils/token.py @@ -53,13 +53,14 @@ def create_id_token(user, aud, nonce='', at_hash='', request=None, scope=[]): if ('email' in scope) and getattr(user, 'email', None): dic['email'] = user.email - processing_hook = settings.get('OIDC_IDTOKEN_PROCESSING_HOOK') + processing_hooks = settings.get('OIDC_IDTOKEN_PROCESSING_HOOK') - if isinstance(processing_hook, (list, tuple)): - for hook in processing_hook: - dic = settings.import_from_str(hook)(dic, user=user) - else: - dic = settings.import_from_str(processing_hook)(dic, user=user) + if not isinstance(processing_hooks, (list, tuple)): + processing_hooks = [processing_hooks] + + for hook_string in processing_hooks: + hook = settings.import_from_str(hook_string) + dic = hook(dic, user=user, scope=scope) return dic diff --git a/oidc_provider/tests/app/utils.py b/oidc_provider/tests/app/utils.py index 4f824ec..31a9aca 100644 --- a/oidc_provider/tests/app/utils.py +++ b/oidc_provider/tests/app/utils.py @@ -107,7 +107,7 @@ def fake_sub_generator(user): return user.email -def fake_idtoken_processing_hook(id_token, user): +def fake_idtoken_processing_hook(id_token, user, scope=None): """ Fake function for inserting some keys into token. Testing OIDC_IDTOKEN_PROCESSING_HOOK. """ @@ -116,10 +116,18 @@ def fake_idtoken_processing_hook(id_token, user): return id_token -def fake_idtoken_processing_hook2(id_token, user): +def fake_idtoken_processing_hook2(id_token, user, scope=None): """ Fake function for inserting some keys into token. Testing OIDC_IDTOKEN_PROCESSING_HOOK - tuple or list as param """ id_token['test_idtoken_processing_hook2'] = FAKE_RANDOM_STRING id_token['test_idtoken_processing_hook_user_email2'] = user.email return id_token + + +def fake_idtoken_processing_hook3(id_token, user, scope=None): + """ + Fake function for checking scope is passed to processing hook. + """ + id_token['scope_passed_to_processing_hook'] = scope + return id_token diff --git a/oidc_provider/tests/test_token_endpoint.py b/oidc_provider/tests/test_token_endpoint.py index 0760555..3fedd83 100644 --- a/oidc_provider/tests/test_token_endpoint.py +++ b/oidc_provider/tests/test_token_endpoint.py @@ -734,6 +734,26 @@ class TokenTestCase(TestCase): self.assertEqual(id_token.get('test_idtoken_processing_hook2'), FAKE_RANDOM_STRING) self.assertEqual(id_token.get('test_idtoken_processing_hook_user_email2'), self.user.email) + @override_settings( + OIDC_IDTOKEN_PROCESSING_HOOK=( + 'oidc_provider.tests.app.utils.fake_idtoken_processing_hook3')) + def test_additional_idtoken_processing_hook_scope_param(self): + """ + Test scope parameter is passed to OIDC_IDTOKEN_PROCESSING_HOOK. + """ + code = self._create_code(['openid', 'email', 'profile', 'dummy']) + + post_data = self._auth_code_post_data(code=code.code) + + response = self._post_request(post_data) + + response_dic = json.loads(response.content.decode('utf-8')) + id_token = JWT().unpack(response_dic['id_token'].encode('utf-8')).payload() + + self.assertEqual( + id_token.get('scope_passed_to_processing_hook'), + ['openid', 'email', 'profile', 'dummy']) + def test_pkce_parameters(self): """ Test Proof Key for Code Exchange by OAuth Public Clients. From 61d88014c9ee8c36832afd56fc422559f40aaed2 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Fri, 20 Apr 2018 11:19:59 -0300 Subject: [PATCH 03/26] Remove duplicate link in docs. --- docs/index.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index be652a5..05edb50 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -39,7 +39,6 @@ Contents: sections/signals sections/examples sections/contribute - sections/contribute sections/changelog .. From 21a64b262cf7f8cb6c96c50621f7461096679276 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Fri, 20 Apr 2018 12:00:38 -0300 Subject: [PATCH 04/26] Move extract_client_auth to oauth2 utils. --- oidc_provider/lib/endpoints/token.py | 30 ++++------------------------ oidc_provider/lib/utils/oauth2.py | 25 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/oidc_provider/lib/endpoints/token.py b/oidc_provider/lib/endpoints/token.py index 3fbff8e..c6b96b0 100644 --- a/oidc_provider/lib/endpoints/token.py +++ b/oidc_provider/lib/endpoints/token.py @@ -1,7 +1,6 @@ -from base64 import b64decode, urlsafe_b64encode +from base64 import urlsafe_b64encode import hashlib import logging -import re from django.contrib.auth import authenticate from django.http import JsonResponse @@ -10,6 +9,7 @@ from oidc_provider.lib.errors import ( TokenError, UserAuthError, ) +from oidc_provider.lib.utils.oauth2 import extract_client_auth from oidc_provider.lib.utils.token import ( create_id_token, create_token, @@ -26,6 +26,7 @@ logger = logging.getLogger(__name__) class TokenEndpoint(object): + def __init__(self, request): self.request = request self.params = {} @@ -33,7 +34,7 @@ class TokenEndpoint(object): self._extract_params() def _extract_params(self): - client_id, client_secret = self._extract_client_auth() + client_id, client_secret = extract_client_auth(self.request) self.params['client_id'] = client_id self.params['client_secret'] = client_secret @@ -49,29 +50,6 @@ class TokenEndpoint(object): self.params['username'] = self.request.POST.get('username', '') self.params['password'] = self.request.POST.get('password', '') - def _extract_client_auth(self): - """ - Get client credentials using HTTP Basic Authentication method. - Or try getting parameters via POST. - See: http://tools.ietf.org/html/rfc6750#section-2.1 - - Return a string. - """ - auth_header = self.request.META.get('HTTP_AUTHORIZATION', '') - - if re.compile('^Basic\s{1}.+$').match(auth_header): - b64_user_pass = auth_header.split()[1] - try: - user_pass = b64decode(b64_user_pass).decode('utf-8').split(':') - client_id, client_secret = tuple(user_pass) - except Exception: - client_id = client_secret = '' - else: - client_id = self.request.POST.get('client_id', '') - client_secret = self.request.POST.get('client_secret', '') - - return (client_id, client_secret) - def validate_params(self): try: self.client = Client.objects.get(client_id=self.params['client_id']) diff --git a/oidc_provider/lib/utils/oauth2.py b/oidc_provider/lib/utils/oauth2.py index bfb7849..452325f 100644 --- a/oidc_provider/lib/utils/oauth2.py +++ b/oidc_provider/lib/utils/oauth2.py @@ -1,3 +1,4 @@ +from base64 import b64decode import logging import re @@ -28,6 +29,30 @@ def extract_access_token(request): return access_token +def extract_client_auth(request): + """ + Get client credentials using HTTP Basic Authentication method. + Or try getting parameters via POST. + See: http://tools.ietf.org/html/rfc6750#section-2.1 + + Return a tuple `(client_id, client_secret)`. + """ + auth_header = request.META.get('HTTP_AUTHORIZATION', '') + + if re.compile('^Basic\s{1}.+$').match(auth_header): + b64_user_pass = auth_header.split()[1] + try: + user_pass = b64decode(b64_user_pass).decode('utf-8').split(':') + client_id, client_secret = tuple(user_pass) + except Exception: + client_id = client_secret = '' + else: + client_id = request.POST.get('client_id', '') + client_secret = request.POST.get('client_secret', '') + + return (client_id, client_secret) + + def protected_resource_view(scopes=None): """ View decorator. The client accesses protected resources by presenting the From 78fbd534529cc86468cb31bb9ec063907248ae37 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Fri, 20 Apr 2018 12:29:48 -0300 Subject: [PATCH 05/26] Drop support for Django 1.7. --- docs/sections/changelog.rst | 2 ++ docs/sections/installation.rst | 2 +- tox.ini | 5 ++--- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/sections/changelog.rst b/docs/sections/changelog.rst index 0c5f0f6..aa770ef 100644 --- a/docs/sections/changelog.rst +++ b/docs/sections/changelog.rst @@ -8,6 +8,8 @@ All notable changes to this project will be documented in this file. Unreleased ========== +* Changed: Dropping support for Django versions before 1.8. + 0.6.0 ===== diff --git a/docs/sections/installation.rst b/docs/sections/installation.rst index bf18a0a..a910193 100644 --- a/docs/sections/installation.rst +++ b/docs/sections/installation.rst @@ -7,7 +7,7 @@ Requirements ============ * Python: ``2.7`` ``3.4`` ``3.5`` ``3.6`` -* Django: ``1.7`` ``1.8`` ``1.9`` ``1.10`` ``1.11`` ``2.0`` +* Django: ``1.8`` ``1.9`` ``1.10`` ``1.11`` ``2.0`` Quick Installation ================== diff --git a/tox.ini b/tox.ini index db00837..27d5ef0 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] envlist= - py27-django{17,18,19,110,111}, - py34-django{17,18,19,110,111,20}, + py27-django{18,19,110,111}, + py34-django{18,19,110,111,20}, py35-django{18,19,110,111,20}, py36-django{18,19,110,111,20}, @@ -15,7 +15,6 @@ deps = pytest-django pytest-flake8 pytest-cov - django17: django>=1.7,<1.8 django18: django>=1.8,<1.9 django19: django>=1.9,<1.10 django110: django>=1.10,<1.11 From 2fa970fc5a21b8dbbb6f2cc8c48668ef927db89b Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Fri, 20 Apr 2018 12:34:14 -0300 Subject: [PATCH 06/26] Remove Django 1.7 for travis. --- .travis.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index 3d5c324..443e5a0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,6 @@ python: - "3.5" - "3.6" env: - - DJANGO=1.7 - DJANGO=1.8 - DJANGO=1.9 - DJANGO=1.10 @@ -15,10 +14,6 @@ matrix: exclude: - python: "2.7" env: DJANGO=2.0 - - python: "3.5" - env: DJANGO=1.7 - - python: "3.6" - env: DJANGO=1.7 install: - pip install tox coveralls script: From eca5b06760b4cf13df236e407f3b2ab828a7b848 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Fri, 20 Apr 2018 18:23:41 -0300 Subject: [PATCH 07/26] Test docs with tox. --- tox.ini | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tox.ini b/tox.ini index 27d5ef0..4a2ade4 100644 --- a/tox.ini +++ b/tox.ini @@ -24,6 +24,16 @@ deps = commands = pytest --flake8 --cov=oidc_provider {posargs} +[testenv:docs] +basepython = python2.7 +changedir = docs +deps = + sphinx + sphinx_rtd_theme +commands = + mkdir -p _static/ + sphinx-build -v -W -b html -d {envtmpdir}/doctrees -D html_static_path="_static" . {envtmpdir}/html + [pytest] DJANGO_SETTINGS_MODULE = oidc_provider.tests.settings python_files = test_*.py From 180aad9a367405ab15069d6de97ab373db54d3df Mon Sep 17 00:00:00 2001 From: Maxim Daniline Date: Mon, 5 Feb 2018 15:29:08 +0000 Subject: [PATCH 08/26] Add token introspection endpoint to satisfy https://tools.ietf.org/html/rfc7662 --- docs/sections/settings.rst | 31 +++++ oidc_provider/admin.py | 41 +++++- oidc_provider/lib/endpoints/introspection.py | 86 +++++++++++++ oidc_provider/lib/errors.py | 9 ++ oidc_provider/lib/utils/common.py | 53 ++++++++ oidc_provider/lib/utils/token.py | 10 +- oidc_provider/migrations/0024_resource.py | 35 +++++ oidc_provider/models.py | 44 +++++++ oidc_provider/settings.py | 12 ++ oidc_provider/tests/app/utils.py | 24 +++- .../tests/test_introspection_endpoint.py | 120 ++++++++++++++++++ oidc_provider/urls.py | 1 + oidc_provider/views.py | 40 +++++- 13 files changed, 492 insertions(+), 14 deletions(-) create mode 100644 oidc_provider/lib/endpoints/introspection.py create mode 100644 oidc_provider/migrations/0024_resource.py create mode 100644 oidc_provider/tests/test_introspection_endpoint.py diff --git a/docs/sections/settings.rst b/docs/sections/settings.rst index b5457c1..8b5ef02 100644 --- a/docs/sections/settings.rst +++ b/docs/sections/settings.rst @@ -21,6 +21,21 @@ If not specified, it will be automatically generated using ``request.scheme`` an For example ``http://localhost:8000``. +OIDC_RESOURCE_MODEL +=================== + +OPTIONAL. ``str``. Path to a custom API resource model. + +Default is ``oidc_provider.Resource``. + +Similar to the Django custom user model, you can extend the default model by adding ``AbstractResource`` as a mixin. + +For example:: + + class CustomResource(AbstractResource): + custom_field = models.CharField(max_length=255, _(u'Some Custom Field')) + + OIDC_AFTER_USERLOGIN_HOOK ========================= @@ -90,6 +105,22 @@ Default is:: return id_token +OIDC_INTROSPECTION_PROCESSING_HOOK +================================== + +OPTIONAL. ``str`` or ``(list, tuple)``. + +A string with the location of your function hook or ``list`` or ``tuple`` with hook functions. +Here you can add extra dictionary values specific to your valid response value for token introspection. + +The function receives an ``introspection_response`` dictionary, a ``resource`` instance and an ``id_token`` dictionary. + +Default is:: + + def default_introspection_processing_hook(introspection_response, resource, id_token): + + return introspection_response + OIDC_IDTOKEN_SUB_GENERATOR ========================== diff --git a/oidc_provider/admin.py b/oidc_provider/admin.py index 7718897..5c695a6 100644 --- a/oidc_provider/admin.py +++ b/oidc_provider/admin.py @@ -6,7 +6,9 @@ from django.forms import ModelForm from django.contrib import admin from django.utils.translation import ugettext_lazy as _ -from oidc_provider.models import Client, Code, Token, RSAKey +from oidc_provider.models import Client, Code, Token, RSAKey, get_resource_model + +Resource = get_resource_model() class ClientForm(ModelForm): @@ -72,6 +74,43 @@ class ClientAdmin(admin.ModelAdmin): raw_id_fields = ['owner'] +class ResourceForm(ModelForm): + def __init__(self, *args, **kwargs): + super(ResourceForm, self).__init__(*args, **kwargs) + self.fields['resource_secret'].required = False + + def clean_resource_secret(self): + if self.cleaned_data['resource_secret']: + secret = self.cleaned_data['resource_secret'] + else: + secret = sha224(uuid4().hex.encode()).hexdigest() + return secret + + class Meta: + model = Resource + exclude = [] + + +@admin.register(Resource) +class ResourceAdmin(admin.ModelAdmin): + fieldsets = [ + [None, { + 'fields': ('name', 'owner', 'active',), + }], + [_('Credentials'), { + 'fields': ('resource_id', 'resource_secret',), + }], + [_('Permissions'), { + 'fields': ('allowed_clients',), + }], + ] + form = ResourceForm + list_display = ['name', 'resource_id', 'date_created'] + readonly_fields = ['date_created'] + search_fields = ['name'] + raw_id_fields = ['owner'] + + @admin.register(Code) class CodeAdmin(admin.ModelAdmin): diff --git a/oidc_provider/lib/endpoints/introspection.py b/oidc_provider/lib/endpoints/introspection.py new file mode 100644 index 0000000..9f9b9e1 --- /dev/null +++ b/oidc_provider/lib/endpoints/introspection.py @@ -0,0 +1,86 @@ +import logging + +from django.http import JsonResponse + +from oidc_provider.lib.errors import TokenIntrospectionError +from oidc_provider.lib.utils.common import get_basic_client_credentials, run_processing_hook +from oidc_provider.models import Token, get_resource_model + + +Resource = get_resource_model() + +logger = logging.getLogger(__name__) + + +class TokenIntrospectionEndpoint(object): + + def __init__(self, request): + self.request = request + self.params = {} + self._extract_params() + + def _extract_params(self): + # Introspection only supports POST requests + self.params['token'] = self.request.POST.get('token') + resource_id, resource_secret = get_basic_client_credentials(self.request) + self.params['resource_id'] = resource_id + self.params['resource_secret'] = resource_secret + + def validate_params(self): + if not (self.params['resource_id'] and self.params['resource_secret']): + logger.debug('[Introspection] No resource credentials provided') + raise TokenIntrospectionError() + if not self.params['token']: + logger.debug('[Introspection] No token provided') + raise TokenIntrospectionError() + try: + token = Token.objects.get(access_token=self.params['token']) + except Token.DoesNotExist: + logger.debug('[Introspection] Token does not exist: %s', self.params['token']) + raise TokenIntrospectionError() + if token.has_expired(): + logger.debug('[Introspection] Token is not valid: %s', self.params['token']) + raise TokenIntrospectionError() + if not token.id_token: + logger.debug('[Introspection] Token not an authentication token: %s', self.params['token']) + raise TokenIntrospectionError() + + self.id_token = token.id_token + audience = self.id_token.get('aud') + if not audience: + logger.debug('[Introspection] No audience found for token: %s', self.params['token']) + raise TokenIntrospectionError() + + try: + self.resource = Resource.objects.get( + resource_id=self.params['resource_id'], + resource_secret=self.params['resource_secret'], + active=True, + allowed_clients__client_id__contains=audience) + except Resource.DoesNotExist: + logger.debug('[Introspection] No valid resource id and audience: %s, %s', + self.params['resource_id'], audience) + raise TokenIntrospectionError() + + def create_response_dic(self): + response_dic = dict((k, self.id_token[k]) for k in ('sub', 'exp', 'iat', 'iss')) + response_dic['active'] = True + response_dic['client_id'] = self.id_token.get('aud') + response_dic['aud'] = self.resource.resource_id + + response_dic = run_processing_hook(response_dic, 'OIDC_INTROSPECTION_PROCESSING_HOOK', + resource=self.resource, + id_token=self.id_token) + + return response_dic + + @classmethod + def response(cls, dic, status=200): + """ + Create and return a response object. + """ + response = JsonResponse(dic, status=status) + response['Cache-Control'] = 'no-store' + response['Pragma'] = 'no-cache' + + return response diff --git a/oidc_provider/lib/errors.py b/oidc_provider/lib/errors.py index 22d8e9a..318fb96 100644 --- a/oidc_provider/lib/errors.py +++ b/oidc_provider/lib/errors.py @@ -32,6 +32,15 @@ class UserAuthError(Exception): } +class TokenIntrospectionError(Exception): + """ + Specific to the introspection endpoint. This error will be converted + to an "active: false" response, as per the spec. + See https://tools.ietf.org/html/rfc7662 + """ + pass + + class AuthorizeError(Exception): _errors = { diff --git a/oidc_provider/lib/utils/common.py b/oidc_provider/lib/utils/common.py index 870d3ee..56c7b58 100644 --- a/oidc_provider/lib/utils/common.py +++ b/oidc_provider/lib/utils/common.py @@ -1,7 +1,11 @@ +from base64 import b64decode from hashlib import sha224 +from django.http import HttpResponse +from oidc_provider import settings import django from django.http import HttpResponse +import re from oidc_provider import settings @@ -12,6 +16,9 @@ else: from django.core.urlresolvers import reverse +basic_re = re.compile('^Basic\s(.+)$', re.I) + + def redirect(uri): """ Custom Response object for redirecting to a Non-HTTP url scheme. @@ -123,6 +130,17 @@ def default_idtoken_processing_hook(id_token, user): return id_token +def default_introspection_processing_hook(introspection_response, resource, id_token): + """ + Hook to customise the returned data from the token introspection endpoint + :param introspection_response: + :param resource: + :param id_token: + :return: + """ + return introspection_response + + def get_browser_state_or_default(request): """ Determine value to use as session state. @@ -130,3 +148,38 @@ def get_browser_state_or_default(request): key = (request.session.session_key or settings.get('OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY')) return sha224(key.encode('utf-8')).hexdigest() + + +def get_basic_client_credentials(request): + """ + Get client credentials using HTTP Basic Authentication method. + Or try getting parameters via POST. + See: http://tools.ietf.org/html/rfc6750#section-2.1 + + :param request: + :return: tuple of client_id, client_secret + :rtype: tuple + """ + auth_header = request.META.get('HTTP_AUTHORIZATION', '') + result = basic_re.match(auth_header) + if result: + b64_user_pass = result.group(1) + try: + user_pass = b64decode(b64_user_pass).decode('utf-8').split(':', 1) + client_id, client_secret = tuple(user_pass) + except (ValueError, UnicodeDecodeError): + client_id = client_secret = '' + else: + client_id = request.POST.get('client_id') + client_secret = request.POST.get('client_secret') + return client_id, client_secret + + +def run_processing_hook(subject, hook_settings_name, **kwargs): + processing_hook = settings.get(hook_settings_name) + if isinstance(processing_hook, (list, tuple)): + for hook in processing_hook: + subject = settings.import_from_str(hook)(subject, **kwargs) + else: + subject = settings.import_from_str(processing_hook)(subject, **kwargs) + return subject diff --git a/oidc_provider/lib/utils/token.py b/oidc_provider/lib/utils/token.py index 2da2c13..a413bc8 100644 --- a/oidc_provider/lib/utils/token.py +++ b/oidc_provider/lib/utils/token.py @@ -9,7 +9,7 @@ from jwkest.jwk import SYMKey from jwkest.jws import JWS from jwkest.jwt import JWT -from oidc_provider.lib.utils.common import get_issuer +from oidc_provider.lib.utils.common import get_issuer, run_processing_hook from oidc_provider.lib.claims import StandardScopeClaims from oidc_provider.models import ( Code, @@ -62,13 +62,7 @@ def create_id_token(token, user, aud, nonce='', at_hash='', request=None, scope= claims = StandardScopeClaims(token).create_response_dic() dic.update(claims) - processing_hook = settings.get('OIDC_IDTOKEN_PROCESSING_HOOK') - - if isinstance(processing_hook, (list, tuple)): - for hook in processing_hook: - dic = settings.import_from_str(hook)(dic, user=user) - else: - dic = settings.import_from_str(processing_hook)(dic, user=user) + dic = run_processing_hook(dic, 'OIDC_IDTOKEN_PROCESSING_HOOK', user=user) return dic diff --git a/oidc_provider/migrations/0024_resource.py b/oidc_provider/migrations/0024_resource.py new file mode 100644 index 0000000..b1d541d --- /dev/null +++ b/oidc_provider/migrations/0024_resource.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10 on 2018-02-05 14:19 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('oidc_provider', '0023_client_owner'), + ] + + operations = [ + migrations.CreateModel( + name='Resource', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(default='', max_length=100, verbose_name='Name')), + ('resource_id', models.CharField(max_length=255, unique=True, verbose_name='Resource ID')), + ('resource_secret', models.CharField(max_length=255, verbose_name='Resource Secret')), + ('date_created', models.DateField(auto_now_add=True, verbose_name='Date Created')), + ('date_updated', models.DateField(auto_now=True, verbose_name='Date Updated')), + ('active', models.BooleanField(default=False, verbose_name='Is Active')), + ('allowed_clients', models.ManyToManyField(blank=True, help_text='Select which clients can be used to access this resource.', related_name='accessible_resources', to='oidc_provider.Client', verbose_name='Allowed Clients')), + ('owner', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='oidc_resource_set', to=settings.AUTH_USER_MODEL, verbose_name='Owner')), + ], + options={ + 'swappable': 'OIDC_RESOURCE_MODEL', + }, + ), + ] diff --git a/oidc_provider/models.py b/oidc_provider/models.py index 411633c..110d48a 100644 --- a/oidc_provider/models.py +++ b/oidc_provider/models.py @@ -4,6 +4,7 @@ import binascii from hashlib import md5, sha256 import json +from django.apps import apps from django.db import models from django.utils import timezone from django.utils.translation import ugettext_lazy as _ @@ -128,6 +129,45 @@ class Client(models.Model): return self.redirect_uris[0] if self.redirect_uris else '' +class AbstractResource(models.Model): + name = models.CharField(max_length=100, default='', verbose_name=_(u'Name')) + owner = models.ForeignKey(settings.AUTH_USER_MODEL, + verbose_name=_(u'Owner'), + blank=True, null=True, default=None, + on_delete=models.SET_NULL, + related_name='oidc_resource_set') + + resource_id = models.CharField(max_length=255, unique=True, verbose_name=_(u'Resource ID')) + resource_secret = models.CharField(max_length=255, verbose_name=_(u'Resource Secret')) + + date_created = models.DateField(auto_now_add=True, verbose_name=_(u'Date Created')) + date_updated = models.DateField(auto_now=True, verbose_name=_(u'Date Updated')) + + active = models.BooleanField(default=False, verbose_name=_(u'Is Active')) + + allowed_clients = models.ManyToManyField(Client, + blank=True, + verbose_name=_(u'Allowed Clients'), + related_name='accessible_resources', + help_text=_(u'Select which clients can be used to access this resource.')) + + def __str__(self): + return u'{0}'.format(self.name) + + def __unicode__(self): + return self.__str__() + + class Meta: + verbose_name = _(u'Resource') + verbose_name_plural = _(u'Resources') + abstract = True + + +class Resource(AbstractResource): + class Meta: + swappable = 'OIDC_RESOURCE_MODEL' + + class BaseCodeTokenModel(models.Model): client = models.ForeignKey(Client, verbose_name=_(u'Client'), on_delete=models.CASCADE) @@ -232,3 +272,7 @@ class RSAKey(models.Model): @property def kid(self): return u'{0}'.format(md5(self.key.encode('utf-8')).hexdigest() if self.key else '') + + +def get_resource_model(): + return apps.get_model(getattr(settings, 'OIDC_RESOURCE_MODEL', 'oidc_provider.Resource')) diff --git a/oidc_provider/settings.py b/oidc_provider/settings.py index 8c1729d..66cf0c3 100644 --- a/oidc_provider/settings.py +++ b/oidc_provider/settings.py @@ -129,6 +129,10 @@ class DefaultSettings(object): """ return 'oidc_provider.lib.utils.common.default_idtoken_processing_hook' + @property + def OIDC_INTROSPECTION_PROCESSING_HOOK(self): + return 'oidc_provider.lib.utils.common.default_introspection_processing_hook' + @property def OIDC_GRANT_TYPE_PASSWORD_ENABLE(self): """ @@ -152,6 +156,14 @@ class DefaultSettings(object): 'error': 'oidc_provider/error.html' } + @property + def OIDC_RESOURCE_MODEL(self): + """ + Model w + :return: + """ + return 'oidc_provider.Resource' + default_settings = DefaultSettings() diff --git a/oidc_provider/tests/app/utils.py b/oidc_provider/tests/app/utils.py index 47e09da..e3f9472 100644 --- a/oidc_provider/tests/app/utils.py +++ b/oidc_provider/tests/app/utils.py @@ -11,7 +11,9 @@ from django.contrib.auth.models import User from oidc_provider.models import ( Client, Code, - Token) + Token, get_resource_model) + +Resource = get_resource_model() FAKE_NONCE = 'cb584e44c43ed6bd0bc2d9c7e242837d' @@ -63,9 +65,22 @@ def create_fake_client(response_type, is_public=False, require_consent=True): return client +def create_fake_resource(allowed_clients, active=True): + resource = Resource(name='Some API', + resource_id=str(random.randint(1, 999999)).zfill(6), + resource_secret=str(random.randint(1, 999999)).zfill(6), + active=active) + resource.name = 'Some API' + resource.save() + resource.allowed_clients.add(*allowed_clients) + resource.save() + + return resource + + def create_fake_token(user, scopes, client): expires_at = timezone.now() + timezone.timedelta(seconds=60) - token = Token(user=user, client=client, expires_at=expires_at) + token = Token(user=user, client=client, expires_at=expires_at, access_token=str(random.randint(1, 999999)).zfill(6)) token.scope = scopes token.save() @@ -126,3 +141,8 @@ def fake_idtoken_processing_hook2(id_token, user): id_token['test_idtoken_processing_hook2'] = FAKE_RANDOM_STRING id_token['test_idtoken_processing_hook_user_email2'] = user.email return id_token + + +def fake_introspection_processing_hook(response_dict, resource, id_token): + response_dict['test_introspection_processing_hook'] = FAKE_RANDOM_STRING + return response_dict diff --git a/oidc_provider/tests/test_introspection_endpoint.py b/oidc_provider/tests/test_introspection_endpoint.py new file mode 100644 index 0000000..bfb0b12 --- /dev/null +++ b/oidc_provider/tests/test_introspection_endpoint.py @@ -0,0 +1,120 @@ +import time +from mock import patch + +from django.utils.encoding import force_text + +from oidc_provider.lib.utils.token import create_id_token + +try: + from urllib.parse import urlencode +except ImportError: + from urllib import urlencode + +from django.core.management import call_command +from django.test import TestCase, RequestFactory, override_settings +from django.core.urlresolvers import reverse +from django.utils import timezone + +from oidc_provider.tests.app.utils import ( + create_fake_user, + create_fake_client, + create_fake_resource, + create_fake_token, + FAKE_RANDOM_STRING) +from oidc_provider.views import TokenIntrospectionView + + +class IntrospectionTestCase(TestCase): + + def setUp(self): + call_command('creatersakey') + self.factory = RequestFactory() + self.user = create_fake_user() + self.client = create_fake_client(response_type='id_token token') + self.resource = create_fake_resource(allowed_clients=[self.client]) + self.scopes = ['openid', 'profile'] + self.token = create_fake_token(self.user, self.scopes, self.client) + self.now = time.time() + with patch('oidc_provider.lib.utils.token.time.time') as time_func: + time_func.return_value = self.now + self.token.id_token = create_id_token(self.user, self.client.client_id) + self.token.save() + + def test_no_client_params_returns_inactive(self): + response = self._make_request(client_id='') + self._assert_inactive(response) + + def test_no_client_secret_returns_inactive(self): + response = self._make_request(client_secret='') + self._assert_inactive(response) + + def test_invalid_client_returns_inactive(self): + response = self._make_request(client_id='invalid') + self._assert_inactive(response) + + def test_token_not_found_returns_inactive(self): + response = self._make_request(access_token='invalid') + self._assert_inactive(response) + + def test_no_allowed_clients_returns_inactive(self): + self.resource.allowed_clients.clear() + self.resource.save() + response = self._make_request() + self._assert_inactive(response) + + def test_resource_inactive_returns_inactive(self): + self.resource.active = False + self.resource.save() + response = self._make_request() + self._assert_inactive(response) + + def test_token_expired_returns_inactive(self): + self.token.expires_at = timezone.now() - timezone.timedelta(seconds=60) + self.token.save() + response = self._make_request() + self._assert_inactive(response) + + def test_valid_request_returns_default_properties(self): + response = self._make_request() + self.assertEqual(response.status_code, 200) + self.assertJSONEqual(force_text(response.content), { + 'active': True, + 'aud': self.resource.resource_id, + 'client_id': self.client.client_id, + 'sub': str(self.user.pk), + 'iat': int(self.now), + 'exp': int(self.now + 600), + 'iss': 'http://localhost:8000/openid', + }) + + @override_settings( + OIDC_INTROSPECTION_PROCESSING_HOOK='oidc_provider.tests.app.utils.fake_introspection_processing_hook') + def test_custom_introspection_hook_called_on_valid_request(self): + response = self._make_request() + self.assertEqual(response.status_code, 200) + self.assertJSONEqual(force_text(response.content), { + 'active': True, + 'aud': self.resource.resource_id, + 'client_id': self.client.client_id, + 'sub': str(self.user.pk), + 'iat': int(self.now), + 'exp': int(self.now + 600), + 'iss': 'http://localhost:8000/openid', + 'test_introspection_processing_hook': FAKE_RANDOM_STRING + }) + + def _assert_inactive(self, response): + self.assertEqual(response.status_code, 200) + self.assertJSONEqual(force_text(response.content), {'active': False}) + + def _make_request(self, **kwargs): + url = reverse('oidc_provider:token-introspection') + data = { + 'client_id': kwargs.get('client_id', self.resource.resource_id), + 'client_secret': kwargs.get('client_secret', self.resource.resource_secret), + 'token': kwargs.get('access_token', self.token.access_token), + } + + request = self.factory.post(url, data=urlencode(data), content_type='application/x-www-form-urlencoded') + + return TokenIntrospectionView.as_view()(request) diff --git a/oidc_provider/urls.py b/oidc_provider/urls.py index 0ad05b0..44cc914 100644 --- a/oidc_provider/urls.py +++ b/oidc_provider/urls.py @@ -17,6 +17,7 @@ urlpatterns = [ url(r'^end-session/?$', views.EndSessionView.as_view(), name='end-session'), url(r'^\.well-known/openid-configuration/?$', views.ProviderInfoView.as_view(), name='provider-info'), + url(r'^introspect/?$', views.TokenIntrospectionView.as_view(), name='token-introspection'), url(r'^jwks/?$', views.JwksView.as_view(), name='jwks'), ] diff --git a/oidc_provider/views.py b/oidc_provider/views.py index 7a19bcc..e66903d 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -1,5 +1,6 @@ import logging +from oidc_provider.lib.endpoints.introspection import TokenIntrospectionEndpoint try: from urllib import urlencode from urlparse import urlsplit, parse_qs, urlunsplit @@ -34,7 +35,8 @@ from oidc_provider.lib.errors import ( ClientIdError, RedirectUriError, TokenError, - UserAuthError) + UserAuthError, + TokenIntrospectionError) from oidc_provider.lib.utils.common import ( redirect, get_site_url, @@ -50,6 +52,25 @@ from oidc_provider.models import ( from oidc_provider import settings from oidc_provider import signals +try: + from urllib import urlencode + from urlparse import urlsplit, parse_qs, urlunsplit +except ImportError: + from urllib.parse import urlsplit, parse_qs, urlunsplit, urlencode + +from Cryptodome.PublicKey import RSA +from django.contrib.auth.views import ( + redirect_to_login, + logout, +) + +import django +if django.VERSION >= (1, 11): + from django.urls import reverse +else: + from django.core.urlresolvers import reverse + + logger = logging.getLogger(__name__) OIDC_TEMPLATES = settings.get('OIDC_TEMPLATES') @@ -230,10 +251,10 @@ class TokenView(View): @protected_resource_view(['openid']) def userinfo(request, *args, **kwargs): """ - Create a diccionary with all the requested claims about the End-User. + Create a dictionary with all the requested claims about the End-User. See: http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse - Return a diccionary. + Return a dictionary. """ token = kwargs['token'] @@ -267,6 +288,7 @@ class ProviderInfoView(View): dic['token_endpoint'] = site_url + reverse('oidc_provider:token') dic['userinfo_endpoint'] = site_url + reverse('oidc_provider:userinfo') dic['end_session_endpoint'] = site_url + reverse('oidc_provider:end-session') + dic['introspection_endpoint'] = site_url + reverse('oidc_provider:token-introspection') types_supported = [x[0] for x in RESPONSE_TYPE_CHOICES] dic['response_types_supported'] = types_supported @@ -356,3 +378,15 @@ class CheckSessionIframeView(View): def get(self, request, *args, **kwargs): return render(request, 'oidc_provider/check_session_iframe.html', kwargs) + + +class TokenIntrospectionView(View): + def post(self, request, *args, **kwargs): + introspection = TokenIntrospectionEndpoint(request) + + try: + introspection.validate_params() + dic = introspection.create_response_dic() + return TokenIntrospectionEndpoint.response(dic) + except TokenIntrospectionError: + return TokenIntrospectionEndpoint.response({'active': False}) From 00f3efa158e7afdfc5b8d7f3dbd4a045f8522526 Mon Sep 17 00:00:00 2001 From: Maxim Daniline Date: Mon, 5 Feb 2018 16:56:37 +0000 Subject: [PATCH 09/26] Skip csrf protection on introspection endpoint --- oidc_provider/views.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/oidc_provider/views.py b/oidc_provider/views.py index e66903d..c0c3abb 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -1,5 +1,7 @@ import logging +from django.views.decorators.csrf import csrf_exempt + from oidc_provider.lib.endpoints.introspection import TokenIntrospectionEndpoint try: from urllib import urlencode @@ -381,6 +383,10 @@ class CheckSessionIframeView(View): class TokenIntrospectionView(View): + @method_decorator(csrf_exempt) + def dispatch(self, request, *args, **kwargs): + return super(TokenIntrospectionView, self).dispatch(request, *args, **kwargs) + def post(self, request, *args, **kwargs): introspection = TokenIntrospectionEndpoint(request) From 8eeaf5cf3366d01a2aa122fc82632d4b39723894 Mon Sep 17 00:00:00 2001 From: Maxim Daniline Date: Mon, 23 Apr 2018 14:59:56 +0100 Subject: [PATCH 10/26] Remove the Resource model --- oidc_provider/admin.py | 41 +------------- oidc_provider/lib/endpoints/introspection.py | 56 +++++++++++-------- oidc_provider/lib/utils/common.py | 36 +----------- oidc_provider/migrations/0024_resource.py | 35 ------------ oidc_provider/models.py | 44 --------------- oidc_provider/settings.py | 20 ++++--- oidc_provider/tests/app/utils.py | 21 +------ .../test_introspection_endpoint.py | 43 +++++++------- oidc_provider/views.py | 18 ------ 9 files changed, 74 insertions(+), 240 deletions(-) delete mode 100644 oidc_provider/migrations/0024_resource.py rename oidc_provider/tests/{ => cases}/test_introspection_endpoint.py (75%) diff --git a/oidc_provider/admin.py b/oidc_provider/admin.py index 5c695a6..7718897 100644 --- a/oidc_provider/admin.py +++ b/oidc_provider/admin.py @@ -6,9 +6,7 @@ from django.forms import ModelForm from django.contrib import admin from django.utils.translation import ugettext_lazy as _ -from oidc_provider.models import Client, Code, Token, RSAKey, get_resource_model - -Resource = get_resource_model() +from oidc_provider.models import Client, Code, Token, RSAKey class ClientForm(ModelForm): @@ -74,43 +72,6 @@ class ClientAdmin(admin.ModelAdmin): raw_id_fields = ['owner'] -class ResourceForm(ModelForm): - def __init__(self, *args, **kwargs): - super(ResourceForm, self).__init__(*args, **kwargs) - self.fields['resource_secret'].required = False - - def clean_resource_secret(self): - if self.cleaned_data['resource_secret']: - secret = self.cleaned_data['resource_secret'] - else: - secret = sha224(uuid4().hex.encode()).hexdigest() - return secret - - class Meta: - model = Resource - exclude = [] - - -@admin.register(Resource) -class ResourceAdmin(admin.ModelAdmin): - fieldsets = [ - [None, { - 'fields': ('name', 'owner', 'active',), - }], - [_('Credentials'), { - 'fields': ('resource_id', 'resource_secret',), - }], - [_('Permissions'), { - 'fields': ('allowed_clients',), - }], - ] - form = ResourceForm - list_display = ['name', 'resource_id', 'date_created'] - readonly_fields = ['date_created'] - search_fields = ['name'] - raw_id_fields = ['owner'] - - @admin.register(Code) class CodeAdmin(admin.ModelAdmin): diff --git a/oidc_provider/lib/endpoints/introspection.py b/oidc_provider/lib/endpoints/introspection.py index 9f9b9e1..91b0a5a 100644 --- a/oidc_provider/lib/endpoints/introspection.py +++ b/oidc_provider/lib/endpoints/introspection.py @@ -3,32 +3,35 @@ import logging from django.http import JsonResponse from oidc_provider.lib.errors import TokenIntrospectionError -from oidc_provider.lib.utils.common import get_basic_client_credentials, run_processing_hook -from oidc_provider.models import Token, get_resource_model - - -Resource = get_resource_model() +from oidc_provider.lib.utils.common import run_processing_hook +from oidc_provider.lib.utils.oauth2 import extract_client_auth +from oidc_provider.models import Token, Client +from oidc_provider import settings logger = logging.getLogger(__name__) +INTROSPECTION_SCOPE = 'token_introspection' + class TokenIntrospectionEndpoint(object): def __init__(self, request): self.request = request self.params = {} + self.id_token = None + self.client = None self._extract_params() def _extract_params(self): # Introspection only supports POST requests self.params['token'] = self.request.POST.get('token') - resource_id, resource_secret = get_basic_client_credentials(self.request) - self.params['resource_id'] = resource_id - self.params['resource_secret'] = resource_secret + client_id, client_secret = extract_client_auth(self.request) + self.params['client_id'] = client_id + self.params['client_secret'] = client_secret def validate_params(self): - if not (self.params['resource_id'] and self.params['resource_secret']): - logger.debug('[Introspection] No resource credentials provided') + if not (self.params['client_id'] and self.params['client_secret']): + logger.debug('[Introspection] No client credentials provided') raise TokenIntrospectionError() if not self.params['token']: logger.debug('[Introspection] No token provided') @@ -42,7 +45,8 @@ class TokenIntrospectionEndpoint(object): logger.debug('[Introspection] Token is not valid: %s', self.params['token']) raise TokenIntrospectionError() if not token.id_token: - logger.debug('[Introspection] Token not an authentication token: %s', self.params['token']) + logger.debug('[Introspection] Token not an authentication token: %s', + self.params['token']) raise TokenIntrospectionError() self.id_token = token.id_token @@ -52,24 +56,32 @@ class TokenIntrospectionEndpoint(object): raise TokenIntrospectionError() try: - self.resource = Resource.objects.get( - resource_id=self.params['resource_id'], - resource_secret=self.params['resource_secret'], - active=True, - allowed_clients__client_id__contains=audience) - except Resource.DoesNotExist: - logger.debug('[Introspection] No valid resource id and audience: %s, %s', - self.params['resource_id'], audience) + self.client = Client.objects.get( + client_id=self.params['client_id'], + client_secret=self.params['client_secret']) + except Client.DoesNotExist: + logger.debug('[Introspection] No valid client for id: %s', + self.params['client_id']) + raise TokenIntrospectionError() + if INTROSPECTION_SCOPE not in self.client.scope: + logger.debug('[Introspection] Client %s does not have introspection scope', + self.params['client_id']) + raise TokenIntrospectionError() + if settings.get('OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE') \ + and audience not in self.client.scope: + logger.debug('[Introspection] Client %s does not audience scope %s', + self.params['client_id'], audience) raise TokenIntrospectionError() def create_response_dic(self): response_dic = dict((k, self.id_token[k]) for k in ('sub', 'exp', 'iat', 'iss')) response_dic['active'] = True response_dic['client_id'] = self.id_token.get('aud') - response_dic['aud'] = self.resource.resource_id + response_dic['aud'] = self.client.client_id - response_dic = run_processing_hook(response_dic, 'OIDC_INTROSPECTION_PROCESSING_HOOK', - resource=self.resource, + response_dic = run_processing_hook(response_dic, + 'OIDC_INTROSPECTION_PROCESSING_HOOK', + client=self.client, id_token=self.id_token) return response_dic diff --git a/oidc_provider/lib/utils/common.py b/oidc_provider/lib/utils/common.py index 56c7b58..b667b38 100644 --- a/oidc_provider/lib/utils/common.py +++ b/oidc_provider/lib/utils/common.py @@ -1,11 +1,7 @@ -from base64 import b64decode from hashlib import sha224 -from django.http import HttpResponse -from oidc_provider import settings import django from django.http import HttpResponse -import re from oidc_provider import settings @@ -16,9 +12,6 @@ else: from django.core.urlresolvers import reverse -basic_re = re.compile('^Basic\s(.+)$', re.I) - - def redirect(uri): """ Custom Response object for redirecting to a Non-HTTP url scheme. @@ -130,11 +123,11 @@ def default_idtoken_processing_hook(id_token, user): return id_token -def default_introspection_processing_hook(introspection_response, resource, id_token): +def default_introspection_processing_hook(introspection_response, client, id_token): """ Hook to customise the returned data from the token introspection endpoint :param introspection_response: - :param resource: + :param client: :param id_token: :return: """ @@ -150,31 +143,6 @@ def get_browser_state_or_default(request): return sha224(key.encode('utf-8')).hexdigest() -def get_basic_client_credentials(request): - """ - Get client credentials using HTTP Basic Authentication method. - Or try getting parameters via POST. - See: http://tools.ietf.org/html/rfc6750#section-2.1 - - :param request: - :return: tuple of client_id, client_secret - :rtype: tuple - """ - auth_header = request.META.get('HTTP_AUTHORIZATION', '') - result = basic_re.match(auth_header) - if result: - b64_user_pass = result.group(1) - try: - user_pass = b64decode(b64_user_pass).decode('utf-8').split(':', 1) - client_id, client_secret = tuple(user_pass) - except (ValueError, UnicodeDecodeError): - client_id = client_secret = '' - else: - client_id = request.POST.get('client_id') - client_secret = request.POST.get('client_secret') - return client_id, client_secret - - def run_processing_hook(subject, hook_settings_name, **kwargs): processing_hook = settings.get(hook_settings_name) if isinstance(processing_hook, (list, tuple)): diff --git a/oidc_provider/migrations/0024_resource.py b/oidc_provider/migrations/0024_resource.py deleted file mode 100644 index b1d541d..0000000 --- a/oidc_provider/migrations/0024_resource.py +++ /dev/null @@ -1,35 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.10 on 2018-02-05 14:19 -from __future__ import unicode_literals - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('oidc_provider', '0023_client_owner'), - ] - - operations = [ - migrations.CreateModel( - name='Resource', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(default='', max_length=100, verbose_name='Name')), - ('resource_id', models.CharField(max_length=255, unique=True, verbose_name='Resource ID')), - ('resource_secret', models.CharField(max_length=255, verbose_name='Resource Secret')), - ('date_created', models.DateField(auto_now_add=True, verbose_name='Date Created')), - ('date_updated', models.DateField(auto_now=True, verbose_name='Date Updated')), - ('active', models.BooleanField(default=False, verbose_name='Is Active')), - ('allowed_clients', models.ManyToManyField(blank=True, help_text='Select which clients can be used to access this resource.', related_name='accessible_resources', to='oidc_provider.Client', verbose_name='Allowed Clients')), - ('owner', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='oidc_resource_set', to=settings.AUTH_USER_MODEL, verbose_name='Owner')), - ], - options={ - 'swappable': 'OIDC_RESOURCE_MODEL', - }, - ), - ] diff --git a/oidc_provider/models.py b/oidc_provider/models.py index 110d48a..411633c 100644 --- a/oidc_provider/models.py +++ b/oidc_provider/models.py @@ -4,7 +4,6 @@ import binascii from hashlib import md5, sha256 import json -from django.apps import apps from django.db import models from django.utils import timezone from django.utils.translation import ugettext_lazy as _ @@ -129,45 +128,6 @@ class Client(models.Model): return self.redirect_uris[0] if self.redirect_uris else '' -class AbstractResource(models.Model): - name = models.CharField(max_length=100, default='', verbose_name=_(u'Name')) - owner = models.ForeignKey(settings.AUTH_USER_MODEL, - verbose_name=_(u'Owner'), - blank=True, null=True, default=None, - on_delete=models.SET_NULL, - related_name='oidc_resource_set') - - resource_id = models.CharField(max_length=255, unique=True, verbose_name=_(u'Resource ID')) - resource_secret = models.CharField(max_length=255, verbose_name=_(u'Resource Secret')) - - date_created = models.DateField(auto_now_add=True, verbose_name=_(u'Date Created')) - date_updated = models.DateField(auto_now=True, verbose_name=_(u'Date Updated')) - - active = models.BooleanField(default=False, verbose_name=_(u'Is Active')) - - allowed_clients = models.ManyToManyField(Client, - blank=True, - verbose_name=_(u'Allowed Clients'), - related_name='accessible_resources', - help_text=_(u'Select which clients can be used to access this resource.')) - - def __str__(self): - return u'{0}'.format(self.name) - - def __unicode__(self): - return self.__str__() - - class Meta: - verbose_name = _(u'Resource') - verbose_name_plural = _(u'Resources') - abstract = True - - -class Resource(AbstractResource): - class Meta: - swappable = 'OIDC_RESOURCE_MODEL' - - class BaseCodeTokenModel(models.Model): client = models.ForeignKey(Client, verbose_name=_(u'Client'), on_delete=models.CASCADE) @@ -272,7 +232,3 @@ class RSAKey(models.Model): @property def kid(self): return u'{0}'.format(md5(self.key.encode('utf-8')).hexdigest() if self.key else '') - - -def get_resource_model(): - return apps.get_model(getattr(settings, 'OIDC_RESOURCE_MODEL', 'oidc_provider.Resource')) diff --git a/oidc_provider/settings.py b/oidc_provider/settings.py index 66cf0c3..1fddbfa 100644 --- a/oidc_provider/settings.py +++ b/oidc_provider/settings.py @@ -131,8 +131,20 @@ class DefaultSettings(object): @property def OIDC_INTROSPECTION_PROCESSING_HOOK(self): + """ + OPTIONAL. A string with the location of your function. + Used to update the response for a valid introspection token request. + """ return 'oidc_provider.lib.utils.common.default_introspection_processing_hook' + @property + def OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE(self): + """ + OPTIONAL: A boolean to specify whether or not to verify that the introspection + resource has the requesting client id as one of its scopes. + """ + return True + @property def OIDC_GRANT_TYPE_PASSWORD_ENABLE(self): """ @@ -156,14 +168,6 @@ class DefaultSettings(object): 'error': 'oidc_provider/error.html' } - @property - def OIDC_RESOURCE_MODEL(self): - """ - Model w - :return: - """ - return 'oidc_provider.Resource' - default_settings = DefaultSettings() diff --git a/oidc_provider/tests/app/utils.py b/oidc_provider/tests/app/utils.py index e3f9472..fdee81c 100644 --- a/oidc_provider/tests/app/utils.py +++ b/oidc_provider/tests/app/utils.py @@ -11,9 +11,7 @@ from django.contrib.auth.models import User from oidc_provider.models import ( Client, Code, - Token, get_resource_model) - -Resource = get_resource_model() + Token) FAKE_NONCE = 'cb584e44c43ed6bd0bc2d9c7e242837d' @@ -65,22 +63,9 @@ def create_fake_client(response_type, is_public=False, require_consent=True): return client -def create_fake_resource(allowed_clients, active=True): - resource = Resource(name='Some API', - resource_id=str(random.randint(1, 999999)).zfill(6), - resource_secret=str(random.randint(1, 999999)).zfill(6), - active=active) - resource.name = 'Some API' - resource.save() - resource.allowed_clients.add(*allowed_clients) - resource.save() - - return resource - - def create_fake_token(user, scopes, client): expires_at = timezone.now() + timezone.timedelta(seconds=60) - token = Token(user=user, client=client, expires_at=expires_at, access_token=str(random.randint(1, 999999)).zfill(6)) + token = Token(user=user, client=client, expires_at=expires_at) token.scope = scopes token.save() @@ -143,6 +128,6 @@ def fake_idtoken_processing_hook2(id_token, user): return id_token -def fake_introspection_processing_hook(response_dict, resource, id_token): +def fake_introspection_processing_hook(response_dict, client, id_token): response_dict['test_introspection_processing_hook'] = FAKE_RANDOM_STRING return response_dict diff --git a/oidc_provider/tests/test_introspection_endpoint.py b/oidc_provider/tests/cases/test_introspection_endpoint.py similarity index 75% rename from oidc_provider/tests/test_introspection_endpoint.py rename to oidc_provider/tests/cases/test_introspection_endpoint.py index bfb0b12..952a454 100644 --- a/oidc_provider/tests/test_introspection_endpoint.py +++ b/oidc_provider/tests/cases/test_introspection_endpoint.py @@ -1,4 +1,7 @@ import time +import random + +import django from mock import patch from django.utils.encoding import force_text @@ -12,16 +15,18 @@ except ImportError: from django.core.management import call_command from django.test import TestCase, RequestFactory, override_settings -from django.core.urlresolvers import reverse from django.utils import timezone from oidc_provider.tests.app.utils import ( create_fake_user, create_fake_client, - create_fake_resource, create_fake_token, FAKE_RANDOM_STRING) from oidc_provider.views import TokenIntrospectionView +if django.VERSION >= (1, 11): + from django.urls import reverse +else: + from django.core.urlresolvers import reverse class IntrospectionTestCase(TestCase): @@ -31,13 +36,15 @@ class IntrospectionTestCase(TestCase): self.factory = RequestFactory() self.user = create_fake_user() self.client = create_fake_client(response_type='id_token token') - self.resource = create_fake_resource(allowed_clients=[self.client]) - self.scopes = ['openid', 'profile'] - self.token = create_fake_token(self.user, self.scopes, self.client) + self.resource = create_fake_client(response_type='id_token token') + self.resource.scope = ['token_introspection', self.client.client_id] + self.resource.save() + self.token = create_fake_token(self.user, self.client.scope, self.client) + self.token.access_token = str(random.randint(1, 999999)).zfill(6) self.now = time.time() with patch('oidc_provider.lib.utils.token.time.time') as time_func: time_func.return_value = self.now - self.token.id_token = create_id_token(self.user, self.client.client_id) + self.token.id_token = create_id_token(self.token, self.user, self.client.client_id) self.token.save() def test_no_client_params_returns_inactive(self): @@ -56,14 +63,8 @@ class IntrospectionTestCase(TestCase): response = self._make_request(access_token='invalid') self._assert_inactive(response) - def test_no_allowed_clients_returns_inactive(self): - self.resource.allowed_clients.clear() - self.resource.save() - response = self._make_request() - self._assert_inactive(response) - - def test_resource_inactive_returns_inactive(self): - self.resource.active = False + def test_scope_no_audience_returns_inactive(self): + self.resource.scope = ['token_introspection'] self.resource.save() response = self._make_request() self._assert_inactive(response) @@ -79,7 +80,7 @@ class IntrospectionTestCase(TestCase): self.assertEqual(response.status_code, 200) self.assertJSONEqual(force_text(response.content), { 'active': True, - 'aud': self.resource.resource_id, + 'aud': self.resource.client_id, 'client_id': self.client.client_id, 'sub': str(self.user.pk), 'iat': int(self.now), @@ -87,14 +88,13 @@ class IntrospectionTestCase(TestCase): 'iss': 'http://localhost:8000/openid', }) - @override_settings( - OIDC_INTROSPECTION_PROCESSING_HOOK='oidc_provider.tests.app.utils.fake_introspection_processing_hook') + @override_settings(OIDC_INTROSPECTION_PROCESSING_HOOK='oidc_provider.tests.app.utils.fake_introspection_processing_hook') # NOQA def test_custom_introspection_hook_called_on_valid_request(self): response = self._make_request() self.assertEqual(response.status_code, 200) self.assertJSONEqual(force_text(response.content), { 'active': True, - 'aud': self.resource.resource_id, + 'aud': self.resource.client_id, 'client_id': self.client.client_id, 'sub': str(self.user.pk), 'iat': int(self.now), @@ -110,11 +110,12 @@ class IntrospectionTestCase(TestCase): def _make_request(self, **kwargs): url = reverse('oidc_provider:token-introspection') data = { - 'client_id': kwargs.get('client_id', self.resource.resource_id), - 'client_secret': kwargs.get('client_secret', self.resource.resource_secret), + 'client_id': kwargs.get('client_id', self.resource.client_id), + 'client_secret': kwargs.get('client_secret', self.resource.client_secret), 'token': kwargs.get('access_token', self.token.access_token), } - request = self.factory.post(url, data=urlencode(data), content_type='application/x-www-form-urlencoded') + request = self.factory.post(url, data=urlencode(data), + content_type='application/x-www-form-urlencoded') return TokenIntrospectionView.as_view()(request) diff --git a/oidc_provider/views.py b/oidc_provider/views.py index c0c3abb..e6f3ddc 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -54,24 +54,6 @@ from oidc_provider.models import ( from oidc_provider import settings from oidc_provider import signals -try: - from urllib import urlencode - from urlparse import urlsplit, parse_qs, urlunsplit -except ImportError: - from urllib.parse import urlsplit, parse_qs, urlunsplit, urlencode - -from Cryptodome.PublicKey import RSA -from django.contrib.auth.views import ( - redirect_to_login, - logout, -) - -import django -if django.VERSION >= (1, 11): - from django.urls import reverse -else: - from django.core.urlresolvers import reverse - logger = logging.getLogger(__name__) From 4f704ab5274eb7f584e9e99e3b89f8290e7c2980 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Mon, 23 Apr 2018 16:00:00 -0300 Subject: [PATCH 11/26] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c6bb6cd..ce1050f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Django OIDC Provider +# Django OpenID Connect Provider [![Python Versions](https://img.shields.io/pypi/pyversions/django-oidc-provider.svg)](https://pypi.python.org/pypi/django-oidc-provider) [![PyPI Versions](https://img.shields.io/pypi/v/django-oidc-provider.svg)](https://pypi.python.org/pypi/django-oidc-provider) From 7c7101d782c9def2743730b38d307da56c53f26d Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Mon, 23 Apr 2018 16:00:33 -0300 Subject: [PATCH 12/26] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ce1050f..074dc01 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ OpenID Connect is a simple identity layer on top of the OAuth 2.0 protocol, whic ## About the package -`django-oidc-provider` can help you providing out of the box all the endpoints, data and logic needed to add OpenID Connect capabilities to your Django projects. +`django-oidc-provider` can help you providing out of the box all the endpoints, data and logic needed to add OpenID Connect (and OAuth2) capabilities to your Django projects. Support for Python 3 and 2. Also latest versions of django. From 20a355d9f52b3b48e1ce2b3d6903c999cec9848a Mon Sep 17 00:00:00 2001 From: Maxim Daniline Date: Tue, 24 Apr 2018 10:09:49 +0100 Subject: [PATCH 13/26] Update settings docs to add extra introspection setting --- docs/sections/settings.rst | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/docs/sections/settings.rst b/docs/sections/settings.rst index 8b5ef02..311c72e 100644 --- a/docs/sections/settings.rst +++ b/docs/sections/settings.rst @@ -21,21 +21,6 @@ If not specified, it will be automatically generated using ``request.scheme`` an For example ``http://localhost:8000``. -OIDC_RESOURCE_MODEL -=================== - -OPTIONAL. ``str``. Path to a custom API resource model. - -Default is ``oidc_provider.Resource``. - -Similar to the Django custom user model, you can extend the default model by adding ``AbstractResource`` as a mixin. - -For example:: - - class CustomResource(AbstractResource): - custom_field = models.CharField(max_length=255, _(u'Some Custom Field')) - - OIDC_AFTER_USERLOGIN_HOOK ========================= @@ -121,6 +106,17 @@ Default is:: return introspection_response + +OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE +========================================== + +OPTIONAL ``bool`` + +A flag which toggles whether the audience is matched against the client resource scope when calling the introspection endpoint. + +Default is ``True``. + + OIDC_IDTOKEN_SUB_GENERATOR ========================== From 5a65ac17f9bd09d34f7fc6aff4c5eccced725db7 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Tue, 24 Apr 2018 11:10:27 -0300 Subject: [PATCH 14/26] Replace resource with client in docs. --- docs/sections/settings.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/sections/settings.rst b/docs/sections/settings.rst index 311c72e..f6f3131 100644 --- a/docs/sections/settings.rst +++ b/docs/sections/settings.rst @@ -98,11 +98,11 @@ OPTIONAL. ``str`` or ``(list, tuple)``. A string with the location of your function hook or ``list`` or ``tuple`` with hook functions. Here you can add extra dictionary values specific to your valid response value for token introspection. -The function receives an ``introspection_response`` dictionary, a ``resource`` instance and an ``id_token`` dictionary. +The function receives an ``introspection_response`` dictionary, a ``client`` instance and an ``id_token`` dictionary. Default is:: - def default_introspection_processing_hook(introspection_response, resource, id_token): + def default_introspection_processing_hook(introspection_response, client, id_token): return introspection_response From eed58197bd6d699c48d96203de2c1e352a385a82 Mon Sep 17 00:00:00 2001 From: Maxim Daniline Date: Thu, 26 Apr 2018 10:12:28 +0100 Subject: [PATCH 15/26] Adjust import order and method order in introspection tests --- .../cases/test_introspection_endpoint.py | 51 +++++++++---------- 1 file changed, 23 insertions(+), 28 deletions(-) diff --git a/oidc_provider/tests/cases/test_introspection_endpoint.py b/oidc_provider/tests/cases/test_introspection_endpoint.py index 952a454..99eab9a 100644 --- a/oidc_provider/tests/cases/test_introspection_endpoint.py +++ b/oidc_provider/tests/cases/test_introspection_endpoint.py @@ -1,32 +1,27 @@ import time import random -import django from mock import patch - -from django.utils.encoding import force_text - -from oidc_provider.lib.utils.token import create_id_token - try: from urllib.parse import urlencode except ImportError: from urllib import urlencode - +from django.utils.encoding import force_text from django.core.management import call_command from django.test import TestCase, RequestFactory, override_settings from django.utils import timezone +try: + from django.urls import reverse +except ImportError: + from django.core.urlresolvers import reverse from oidc_provider.tests.app.utils import ( create_fake_user, create_fake_client, create_fake_token, FAKE_RANDOM_STRING) +from oidc_provider.lib.utils.token import create_id_token from oidc_provider.views import TokenIntrospectionView -if django.VERSION >= (1, 11): - from django.urls import reverse -else: - from django.core.urlresolvers import reverse class IntrospectionTestCase(TestCase): @@ -47,6 +42,23 @@ class IntrospectionTestCase(TestCase): self.token.id_token = create_id_token(self.token, self.user, self.client.client_id) self.token.save() + def _assert_inactive(self, response): + self.assertEqual(response.status_code, 200) + self.assertJSONEqual(force_text(response.content), {'active': False}) + + def _make_request(self, **kwargs): + url = reverse('oidc_provider:token-introspection') + data = { + 'client_id': kwargs.get('client_id', self.resource.client_id), + 'client_secret': kwargs.get('client_secret', self.resource.client_secret), + 'token': kwargs.get('access_token', self.token.access_token), + } + + request = self.factory.post(url, data=urlencode(data), + content_type='application/x-www-form-urlencoded') + + return TokenIntrospectionView.as_view()(request) + def test_no_client_params_returns_inactive(self): response = self._make_request(client_id='') self._assert_inactive(response) @@ -102,20 +114,3 @@ class IntrospectionTestCase(TestCase): 'iss': 'http://localhost:8000/openid', 'test_introspection_processing_hook': FAKE_RANDOM_STRING }) - - def _assert_inactive(self, response): - self.assertEqual(response.status_code, 200) - self.assertJSONEqual(force_text(response.content), {'active': False}) - - def _make_request(self, **kwargs): - url = reverse('oidc_provider:token-introspection') - data = { - 'client_id': kwargs.get('client_id', self.resource.client_id), - 'client_secret': kwargs.get('client_secret', self.resource.client_secret), - 'token': kwargs.get('access_token', self.token.access_token), - } - - request = self.factory.post(url, data=urlencode(data), - content_type='application/x-www-form-urlencoded') - - return TokenIntrospectionView.as_view()(request) From 9a24257305dfe55fbba06959c002e683508aac76 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Fri, 27 Apr 2018 10:33:39 -0300 Subject: [PATCH 16/26] Update changelog.rst --- docs/sections/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/sections/changelog.rst b/docs/sections/changelog.rst index aa770ef..13470cd 100644 --- a/docs/sections/changelog.rst +++ b/docs/sections/changelog.rst @@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file. Unreleased ========== +* Added: toke instrospection endpoint support. * Changed: Dropping support for Django versions before 1.8. 0.6.0 From 948745d6ff87fbb7d566eadc5b723a049ba4846b Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Fri, 27 Apr 2018 10:34:48 -0300 Subject: [PATCH 17/26] Update changelog.rst --- docs/sections/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sections/changelog.rst b/docs/sections/changelog.rst index 13470cd..ddf157c 100644 --- a/docs/sections/changelog.rst +++ b/docs/sections/changelog.rst @@ -8,7 +8,7 @@ All notable changes to this project will be documented in this file. Unreleased ========== -* Added: toke instrospection endpoint support. +* Added: token instrospection endpoint support (RFC7662). * Changed: Dropping support for Django versions before 1.8. 0.6.0 From f132e041d58229cf96543e820b164ebd7d84a173 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Fri, 27 Apr 2018 14:47:07 -0300 Subject: [PATCH 18/26] Update setup.py --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 7d804ad..5152c26 100644 --- a/setup.py +++ b/setup.py @@ -20,6 +20,7 @@ setup( author='Juan Ignacio Fiorentino', author_email='juanifioren@gmail.com', classifiers=[ + 'Development Status :: 5 - Production/Stable', 'Environment :: Web Environment', 'Framework :: Django', 'Intended Audience :: Developers', From 713d15297c37103ed584b1a31e1e341888c534c5 Mon Sep 17 00:00:00 2001 From: Andy Clayton Date: Wed, 22 Nov 2017 11:40:58 -0600 Subject: [PATCH 19/26] include request in password grant authenticate call An an example this can be used to help implement measures against brute force attacks and to alert on or mitigate other untrusted authentication attempts. --- oidc_provider/lib/endpoints/token.py | 8 ++++++++ oidc_provider/tests/app/utils.py | 11 +++++++++++ oidc_provider/tests/cases/test_token_endpoint.py | 12 ++++++++++++ 3 files changed, 31 insertions(+) diff --git a/oidc_provider/lib/endpoints/token.py b/oidc_provider/lib/endpoints/token.py index c6b96b0..8c32046 100644 --- a/oidc_provider/lib/endpoints/token.py +++ b/oidc_provider/lib/endpoints/token.py @@ -1,3 +1,4 @@ +import inspect from base64 import urlsafe_b64encode import hashlib import logging @@ -96,7 +97,14 @@ class TokenEndpoint(object): if not settings.get('OIDC_GRANT_TYPE_PASSWORD_ENABLE'): raise TokenError('unsupported_grant_type') + auth_args = (self.request,) + try: + inspect.getcallargs(authenticate, *auth_args) + except TypeError: + auth_args = () + user = authenticate( + *auth_args, username=self.params['username'], password=self.params['password'] ) diff --git a/oidc_provider/tests/app/utils.py b/oidc_provider/tests/app/utils.py index fdee81c..457757d 100644 --- a/oidc_provider/tests/app/utils.py +++ b/oidc_provider/tests/app/utils.py @@ -1,5 +1,9 @@ import random import string + +import django +from django.contrib.auth.backends import ModelBackend + try: from urlparse import parse_qs, urlsplit except ImportError: @@ -131,3 +135,10 @@ def fake_idtoken_processing_hook2(id_token, user): def fake_introspection_processing_hook(response_dict, client, id_token): response_dict['test_introspection_processing_hook'] = FAKE_RANDOM_STRING return response_dict + + +class TestAuthBackend: + def authenticate(self, *args, **kwargs): + if django.VERSION[0] >= 2 or (django.VERSION[0] == 1 and django.VERSION[1] >= 11): + assert len(args) > 0 and args[0] + return ModelBackend().authenticate(*args, **kwargs) diff --git a/oidc_provider/tests/cases/test_token_endpoint.py b/oidc_provider/tests/cases/test_token_endpoint.py index 5c787b3..0646046 100644 --- a/oidc_provider/tests/cases/test_token_endpoint.py +++ b/oidc_provider/tests/cases/test_token_endpoint.py @@ -3,6 +3,7 @@ import time import uuid from base64 import b64encode + try: from urllib.parse import urlencode except ImportError: @@ -256,6 +257,17 @@ class TokenTestCase(TestCase): else: self.assertNotIn(claim, userinfo) + @override_settings(OIDC_GRANT_TYPE_PASSWORD_ENABLE=True, + AUTHENTICATION_BACKENDS=("oidc_provider.tests.app.utils.TestAuthBackend",)) + def test_password_grant_passes_request_to_backend(self): + response = self._post_request( + post_data=self._password_grant_post_data(), + extras=self._password_grant_auth_header() + ) + + response_dict = json.loads(response.content.decode('utf-8')) + self.assertIn('access_token', response_dict) + @override_settings(OIDC_TOKEN_EXPIRE=720) def test_authorization_code(self): """ From 58bd8ec62cb21c1d84d7d9ecfb321a49bc40ad4f Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Tue, 15 May 2018 10:45:05 -0300 Subject: [PATCH 20/26] Update changelog.rst --- docs/sections/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/sections/changelog.rst b/docs/sections/changelog.rst index ddf157c..94a7840 100644 --- a/docs/sections/changelog.rst +++ b/docs/sections/changelog.rst @@ -9,6 +9,7 @@ Unreleased ========== * Added: token instrospection endpoint support (RFC7662). +* Added: request in password grant authenticate call. * Changed: Dropping support for Django versions before 1.8. 0.6.0 From b0a82aa4ab6a6d45b0b5128d7f1bd855bd565860 Mon Sep 17 00:00:00 2001 From: Tuomas Suutari Date: Thu, 24 May 2018 00:24:52 +0300 Subject: [PATCH 21/26] Pass token and request to OIDC_ID_TOKEN_PROCESSING_HOOK The ID token processing hook might need the token or request too, so make them available. --- oidc_provider/lib/utils/common.py | 13 ++++++-- oidc_provider/lib/utils/token.py | 2 +- oidc_provider/tests/app/utils.py | 17 ++++++++-- .../tests/cases/test_token_endpoint.py | 31 ++++++++++++++++--- 4 files changed, 51 insertions(+), 12 deletions(-) diff --git a/oidc_provider/lib/utils/common.py b/oidc_provider/lib/utils/common.py index c1d913f..0ecc95f 100644 --- a/oidc_provider/lib/utils/common.py +++ b/oidc_provider/lib/utils/common.py @@ -107,9 +107,10 @@ def default_after_end_session_hook( return None -def default_idtoken_processing_hook(id_token, user, scope=None): +def default_idtoken_processing_hook( + id_token, user, scope, token, request, **kwargs): """ - Hook to perform some additional actions ti `id_token` dictionary just before serialization. + Hook for modifying `id_token` just before serialization. :param id_token: dictionary contains values that going to be serialized into `id_token` :type id_token: dict @@ -120,8 +121,14 @@ def default_idtoken_processing_hook(id_token, user, scope=None): :param scope: scope for the token :type scope: list[str]|None + :param token: the Token object created for the authentication request + :type token: oidc_provider.models.Token + + :param request: the request initiating this ID token processing + :type request: django.http.HttpRequest + :return: custom modified dictionary of values for `id_token` - :rtype dict + :rtype: dict """ return id_token diff --git a/oidc_provider/lib/utils/token.py b/oidc_provider/lib/utils/token.py index 264c268..089ce45 100644 --- a/oidc_provider/lib/utils/token.py +++ b/oidc_provider/lib/utils/token.py @@ -64,7 +64,7 @@ def create_id_token(token, user, aud, nonce='', at_hash='', request=None, scope= dic = run_processing_hook( dic, 'OIDC_IDTOKEN_PROCESSING_HOOK', - user=user, scope=scope) + user=user, scope=scope, token=token, request=request) return dic diff --git a/oidc_provider/tests/app/utils.py b/oidc_provider/tests/app/utils.py index 6ab07f2..63ddc8d 100644 --- a/oidc_provider/tests/app/utils.py +++ b/oidc_provider/tests/app/utils.py @@ -113,7 +113,7 @@ def fake_sub_generator(user): return user.email -def fake_idtoken_processing_hook(id_token, user, scope=None): +def fake_idtoken_processing_hook(id_token, user, **kwargs): """ Fake function for inserting some keys into token. Testing OIDC_IDTOKEN_PROCESSING_HOOK. """ @@ -122,7 +122,7 @@ def fake_idtoken_processing_hook(id_token, user, scope=None): return id_token -def fake_idtoken_processing_hook2(id_token, user, scope=None): +def fake_idtoken_processing_hook2(id_token, user, **kwargs): """ Fake function for inserting some keys into token. Testing OIDC_IDTOKEN_PROCESSING_HOOK - tuple or list as param @@ -132,7 +132,7 @@ def fake_idtoken_processing_hook2(id_token, user, scope=None): return id_token -def fake_idtoken_processing_hook3(id_token, user, scope=None): +def fake_idtoken_processing_hook3(id_token, user, scope=None, **kwargs): """ Fake function for checking scope is passed to processing hook. """ @@ -140,6 +140,17 @@ def fake_idtoken_processing_hook3(id_token, user, scope=None): return id_token +def fake_idtoken_processing_hook4(id_token, user, **kwargs): + """ + Fake function for checking kwargs passed to processing hook. + """ + id_token['kwargs_passed_to_processing_hook'] = { + key: repr(value) + for (key, value) in kwargs.items() + } + return id_token + + def fake_introspection_processing_hook(response_dict, client, id_token): response_dict['test_introspection_processing_hook'] = FAKE_RANDOM_STRING return response_dict diff --git a/oidc_provider/tests/cases/test_token_endpoint.py b/oidc_provider/tests/cases/test_token_endpoint.py index d0e3703..fcedd35 100644 --- a/oidc_provider/tests/cases/test_token_endpoint.py +++ b/oidc_provider/tests/cases/test_token_endpoint.py @@ -735,7 +735,31 @@ class TokenTestCase(TestCase): """ Test scope parameter is passed to OIDC_IDTOKEN_PROCESSING_HOOK. """ - code = self._create_code(['openid', 'email', 'profile', 'dummy']) + id_token = self._request_id_token_with_scope( + ['openid', 'email', 'profile', 'dummy']) + self.assertEqual( + id_token.get('scope_passed_to_processing_hook'), + ['openid', 'email', 'profile', 'dummy']) + + @override_settings( + OIDC_IDTOKEN_PROCESSING_HOOK=( + 'oidc_provider.tests.app.utils.fake_idtoken_processing_hook4')) + def test_additional_idtoken_processing_hook_kwargs(self): + """ + Test correct kwargs are passed to OIDC_IDTOKEN_PROCESSING_HOOK. + """ + id_token = self._request_id_token_with_scope(['openid', 'profile']) + kwargs_passed = id_token.get('kwargs_passed_to_processing_hook') + assert kwargs_passed + self.assertEqual(kwargs_passed.get('scope'), + repr([u'openid', u'profile'])) + self.assertEqual(kwargs_passed.get('token'), + '') + self.assertEqual(kwargs_passed.get('request'), + "") + + def _request_id_token_with_scope(self, scope): + code = self._create_code(scope) post_data = self._auth_code_post_data(code=code.code) @@ -743,10 +767,7 @@ class TokenTestCase(TestCase): response_dic = json.loads(response.content.decode('utf-8')) id_token = JWT().unpack(response_dic['id_token'].encode('utf-8')).payload() - - self.assertEqual( - id_token.get('scope_passed_to_processing_hook'), - ['openid', 'email', 'profile', 'dummy']) + return id_token def test_pkce_parameters(self): """ From 7eb31574ee540687cd41ae1befcc7f6a6764f423 Mon Sep 17 00:00:00 2001 From: Tuomas Suutari Date: Thu, 24 May 2018 01:09:09 +0300 Subject: [PATCH 22/26] Document the new ID token processing hook parameters --- docs/sections/settings.rst | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/sections/settings.rst b/docs/sections/settings.rst index f6f3131..fbed9fc 100644 --- a/docs/sections/settings.rst +++ b/docs/sections/settings.rst @@ -81,12 +81,28 @@ Here you can add extra dictionary values specific for your app into id_token. The ``list`` or ``tuple`` is useful when you want to set multiple hooks, i.e. one for permissions and second for some special field. -The function receives a ``id_token`` dictionary and ``user`` instance -and returns it with additional fields. +The hook function receives following arguments: + + * ``id_token``: the ID token dictionary which contains at least the + basic claims (``iss``, ``sub``, ``aud``, ``exp``, ``iat``, + ``auth_time``), but may also contain other claims. If several + processing hooks are configured, then the claims of the previous hook + are also present in the passed dictionary. + * ``user``: User object of the authenticating user, + * ``scope``: the authorized scopes as list of strings or None, + * ``token``: the Token object created for the authentication request, and + * ``request``: Django request object of the authentication request. + +The hook function should return the modified ID token as dictionary. + +.. note:: + It is a good idea to add ``**kwargs`` to the hook function argument + list so that the hook function will work even if new arguments are + added to the hook function call signature. Default is:: - def default_idtoken_processing_hook(id_token, user): + def default_idtoken_processing_hook(id_token, user, scope, token, request, **kwargs): return id_token From ac509f7a822d11cb4e657ff4eb56ce669b09b180 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Thu, 24 May 2018 12:29:35 -0300 Subject: [PATCH 23/26] Update changelog.rst --- docs/sections/changelog.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/sections/changelog.rst b/docs/sections/changelog.rst index 94a7840..0e06249 100644 --- a/docs/sections/changelog.rst +++ b/docs/sections/changelog.rst @@ -10,7 +10,8 @@ Unreleased * Added: token instrospection endpoint support (RFC7662). * Added: request in password grant authenticate call. -* Changed: Dropping support for Django versions before 1.8. +* Changed: dropping support for Django versions before 1.8. +* Changed: pass scope, token and request to OIDC_IDTOKEN_PROCESSING_HOOK. 0.6.0 ===== From acc3cf588b21af9f7e32986980e6c1fc889ff3cd Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Mon, 28 May 2018 12:38:53 -0300 Subject: [PATCH 24/26] Update settings.rst --- docs/sections/settings.rst | 41 +++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/docs/sections/settings.rst b/docs/sections/settings.rst index fbed9fc..ebcb64e 100644 --- a/docs/sections/settings.rst +++ b/docs/sections/settings.rst @@ -64,6 +64,13 @@ Used to add extra scopes specific for your app. OpenID Connect RP's will use sco Read more about how to implement it in :ref:`scopesclaims` section. +OIDC_IDTOKEN_INCLUDE_CLAIMS +============================== + +OPTIONAL. ``bool``. If enabled, id_token will include standard claims of the user (email, first name, etc.). + +Default is ``False``. + OIDC_IDTOKEN_EXPIRE =================== @@ -106,6 +113,19 @@ Default is:: return id_token +OIDC_IDTOKEN_SUB_GENERATOR +========================== + +OPTIONAL. ``str``. A string with the location of your function. ``sub`` is a locally unique and never reassigned identifier within the Issuer for the End-User, which is intended to be consumed by the Client. + +The function receives a ``user`` object and returns a unique ``string`` for the given user. + +Default is:: + + def default_sub_generator(user): + + return str(user.id) + OIDC_INTROSPECTION_PROCESSING_HOOK ================================== @@ -132,27 +152,6 @@ A flag which toggles whether the audience is matched against the client resource Default is ``True``. - -OIDC_IDTOKEN_SUB_GENERATOR -========================== - -OPTIONAL. ``str``. A string with the location of your function. ``sub`` is a locally unique and never reassigned identifier within the Issuer for the End-User, which is intended to be consumed by the Client. - -The function receives a ``user`` object and returns a unique ``string`` for the given user. - -Default is:: - - def default_sub_generator(user): - - return str(user.id) - -OIDC_IDTOKEN_INCLUDE_CLAIMS -============================== - -OPTIONAL. ``bool``. If enabled, id_token will include standard claims of the user (email, first name, etc.). - -Default is ``False``. - OIDC_SESSION_MANAGEMENT_ENABLE ============================== From 122b5c19fdc045cf5b86367a5bffba7dcf945b1b Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Mon, 28 May 2018 12:50:03 -0300 Subject: [PATCH 25/26] Update installation.rst --- docs/sections/installation.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sections/installation.rst b/docs/sections/installation.rst index a910193..3a92626 100644 --- a/docs/sections/installation.rst +++ b/docs/sections/installation.rst @@ -12,7 +12,7 @@ Requirements Quick Installation ================== -If you want to get started fast see our ``/example_project`` folder in your local installation. Or look at it `on github `_. +If you want to get started fast see our ``/example`` folder in your local installation. Or look at it `on github `_. Install the package using pip:: From c95497dbd99840048d9f4f653f618145ee94aaf8 Mon Sep 17 00:00:00 2001 From: Tuomas Suutari Date: Thu, 31 May 2018 10:23:58 +0300 Subject: [PATCH 26/26] Remove scope param from OIDC_IDTOKEN_PROCESSING_HOOK There is no need to pass in the scope parameter separately, since the scope is available via the token parameter already. --- docs/sections/changelog.rst | 2 +- docs/sections/settings.rst | 3 +-- oidc_provider/lib/utils/common.py | 5 +---- oidc_provider/lib/utils/token.py | 2 +- oidc_provider/tests/app/utils.py | 4 ++-- oidc_provider/tests/cases/test_token_endpoint.py | 9 ++++----- 6 files changed, 10 insertions(+), 15 deletions(-) diff --git a/docs/sections/changelog.rst b/docs/sections/changelog.rst index 0e06249..8ddf0be 100644 --- a/docs/sections/changelog.rst +++ b/docs/sections/changelog.rst @@ -11,7 +11,7 @@ Unreleased * Added: token instrospection endpoint support (RFC7662). * Added: request in password grant authenticate call. * Changed: dropping support for Django versions before 1.8. -* Changed: pass scope, token and request to OIDC_IDTOKEN_PROCESSING_HOOK. +* Changed: pass token and request to OIDC_IDTOKEN_PROCESSING_HOOK. 0.6.0 ===== diff --git a/docs/sections/settings.rst b/docs/sections/settings.rst index ebcb64e..93ef62a 100644 --- a/docs/sections/settings.rst +++ b/docs/sections/settings.rst @@ -96,7 +96,6 @@ The hook function receives following arguments: processing hooks are configured, then the claims of the previous hook are also present in the passed dictionary. * ``user``: User object of the authenticating user, - * ``scope``: the authorized scopes as list of strings or None, * ``token``: the Token object created for the authentication request, and * ``request``: Django request object of the authentication request. @@ -109,7 +108,7 @@ The hook function should return the modified ID token as dictionary. Default is:: - def default_idtoken_processing_hook(id_token, user, scope, token, request, **kwargs): + def default_idtoken_processing_hook(id_token, user, token, request, **kwargs): return id_token diff --git a/oidc_provider/lib/utils/common.py b/oidc_provider/lib/utils/common.py index 0ecc95f..65157bb 100644 --- a/oidc_provider/lib/utils/common.py +++ b/oidc_provider/lib/utils/common.py @@ -108,7 +108,7 @@ def default_after_end_session_hook( def default_idtoken_processing_hook( - id_token, user, scope, token, request, **kwargs): + id_token, user, token, request, **kwargs): """ Hook for modifying `id_token` just before serialization. @@ -118,9 +118,6 @@ def default_idtoken_processing_hook( :param user: user for whom id_token is generated :type user: User - :param scope: scope for the token - :type scope: list[str]|None - :param token: the Token object created for the authentication request :type token: oidc_provider.models.Token diff --git a/oidc_provider/lib/utils/token.py b/oidc_provider/lib/utils/token.py index 089ce45..d3fd3ab 100644 --- a/oidc_provider/lib/utils/token.py +++ b/oidc_provider/lib/utils/token.py @@ -64,7 +64,7 @@ def create_id_token(token, user, aud, nonce='', at_hash='', request=None, scope= dic = run_processing_hook( dic, 'OIDC_IDTOKEN_PROCESSING_HOOK', - user=user, scope=scope, token=token, request=request) + user=user, token=token, request=request) return dic diff --git a/oidc_provider/tests/app/utils.py b/oidc_provider/tests/app/utils.py index 63ddc8d..0fc33b0 100644 --- a/oidc_provider/tests/app/utils.py +++ b/oidc_provider/tests/app/utils.py @@ -132,11 +132,11 @@ def fake_idtoken_processing_hook2(id_token, user, **kwargs): return id_token -def fake_idtoken_processing_hook3(id_token, user, scope=None, **kwargs): +def fake_idtoken_processing_hook3(id_token, user, token, **kwargs): """ Fake function for checking scope is passed to processing hook. """ - id_token['scope_passed_to_processing_hook'] = scope + id_token['scope_of_token_passed_to_processing_hook'] = token.scope return id_token diff --git a/oidc_provider/tests/cases/test_token_endpoint.py b/oidc_provider/tests/cases/test_token_endpoint.py index fcedd35..e984df3 100644 --- a/oidc_provider/tests/cases/test_token_endpoint.py +++ b/oidc_provider/tests/cases/test_token_endpoint.py @@ -731,14 +731,14 @@ class TokenTestCase(TestCase): @override_settings( OIDC_IDTOKEN_PROCESSING_HOOK=( 'oidc_provider.tests.app.utils.fake_idtoken_processing_hook3')) - def test_additional_idtoken_processing_hook_scope_param(self): + def test_additional_idtoken_processing_hook_scope_available(self): """ - Test scope parameter is passed to OIDC_IDTOKEN_PROCESSING_HOOK. + Test scope is available in OIDC_IDTOKEN_PROCESSING_HOOK. """ id_token = self._request_id_token_with_scope( ['openid', 'email', 'profile', 'dummy']) self.assertEqual( - id_token.get('scope_passed_to_processing_hook'), + id_token.get('scope_of_token_passed_to_processing_hook'), ['openid', 'email', 'profile', 'dummy']) @override_settings( @@ -751,12 +751,11 @@ class TokenTestCase(TestCase): id_token = self._request_id_token_with_scope(['openid', 'profile']) kwargs_passed = id_token.get('kwargs_passed_to_processing_hook') assert kwargs_passed - self.assertEqual(kwargs_passed.get('scope'), - repr([u'openid', u'profile'])) self.assertEqual(kwargs_passed.get('token'), '') self.assertEqual(kwargs_passed.get('request'), "") + self.assertEqual(set(kwargs_passed.keys()), {'token', 'request'}) def _request_id_token_with_scope(self, scope): code = self._create_code(scope)