From ba7a4fbb96f3b3622b0e23110da0f3642f26e823 Mon Sep 17 00:00:00 2001 From: Franciszek Piszcz Date: Sun, 23 Apr 2017 15:27:59 +0200 Subject: [PATCH 01/71] Don't pin exact versions in install_requires. According to this: https://packaging.python.org/requirements/#install-requires-vs-requirements-files , dependencies should not be pinned to exact versions. --- setup.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index dde8f7e..455ced2 100644 --- a/setup.py +++ b/setup.py @@ -36,11 +36,11 @@ setup( ], test_suite='runtests.runtests', tests_require=[ - 'pyjwkest==1.3.0', - 'mock==2.0.0', + 'pyjwkest>=1.3.0', + 'mock>=2.0.0', ], install_requires=[ - 'pyjwkest==1.3.0', + 'pyjwkest>=1.3.0', ], ) From f07327a71387180cf4fd773feb0e693c2714468f Mon Sep 17 00:00:00 2001 From: Wojciech Bartosiak Date: Tue, 6 Jun 2017 11:12:37 +0200 Subject: [PATCH 02/71] Bug #187 prompt handling (#188) prompt parameter changed to list of strings not a simple string --- oidc_provider/lib/endpoints/authorize.py | 5 +- .../tests/test_authorize_endpoint.py | 92 ++++++++++++++++++- oidc_provider/views.py | 53 ++++++----- 3 files changed, 121 insertions(+), 29 deletions(-) diff --git a/oidc_provider/lib/endpoints/authorize.py b/oidc_provider/lib/endpoints/authorize.py index 36b4b2d..aae16ed 100644 --- a/oidc_provider/lib/endpoints/authorize.py +++ b/oidc_provider/lib/endpoints/authorize.py @@ -36,6 +36,7 @@ logger = logging.getLogger(__name__) class AuthorizeEndpoint(object): + _allowed_prompt_params = {'none', 'login', 'consent', 'select_account'} def __init__(self, request): self.request = request @@ -74,7 +75,9 @@ class AuthorizeEndpoint(object): self.params['scope'] = query_dict.get('scope', '').split() self.params['state'] = query_dict.get('state', '') self.params['nonce'] = query_dict.get('nonce', '') - self.params['prompt'] = query_dict.get('prompt', '') + + self.params['prompt'] = self._allowed_prompt_params.intersection(set(query_dict.get('prompt', '').split())) + self.params['code_challenge'] = query_dict.get('code_challenge', '') self.params['code_challenge_method'] = query_dict.get('code_challenge_method', '') diff --git a/oidc_provider/tests/test_authorize_endpoint.py b/oidc_provider/tests/test_authorize_endpoint.py index 6cb83f7..af104f0 100644 --- a/oidc_provider/tests/test_authorize_endpoint.py +++ b/oidc_provider/tests/test_authorize_endpoint.py @@ -278,7 +278,7 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): self.assertIn('Request for Permission', response.content.decode('utf-8')) - def test_prompt_parameter(self): + def test_prompt_none_parameter(self): """ Specifies whether the Authorization Server prompts the End-User for reauthentication and consent. See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest @@ -289,10 +289,9 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): 'redirect_uri': self.client.default_redirect_uri, 'scope': 'openid email', 'state': self.state, + 'prompt': 'none' } - data['prompt'] = 'none' - response = self._auth_request('get', data) # An error is returned if an End-User is not already authenticated. @@ -301,7 +300,92 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): response = self._auth_request('get', data, is_user_authenticated=True) # An error is returned if the Client does not have pre-configured consent for the requested Claims. - self.assertIn('interaction_required', response['Location']) + self.assertIn('consent_required', response['Location']) + + @patch('oidc_provider.views.django_user_logout') + def test_prompt_login_parameter(self, logout_function): + """ + Specifies whether the Authorization Server prompts the End-User for reauthentication and consent. + See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest + """ + data = { + 'client_id': self.client.client_id, + 'response_type': self.client.response_type, + 'redirect_uri': self.client.default_redirect_uri, + 'scope': 'openid email', + 'state': self.state, + 'prompt': 'login' + } + + response = self._auth_request('get', data) + self.assertIn(settings.get('OIDC_LOGIN_URL'), response['Location']) + + response = self._auth_request('get', data, is_user_authenticated=True) + self.assertIn(settings.get('OIDC_LOGIN_URL'), response['Location']) + self.assertTrue(logout_function.called_once()) + + def test_prompt_login_none_parameter(self): + """ + Specifies whether the Authorization Server prompts the End-User for reauthentication and consent. + See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest + """ + data = { + 'client_id': self.client.client_id, + 'response_type': self.client.response_type, + 'redirect_uri': self.client.default_redirect_uri, + 'scope': 'openid email', + 'state': self.state, + 'prompt': 'login none' + } + + response = self._auth_request('get', data) + self.assertIn('login_required', response['Location']) + + response = self._auth_request('get', data, is_user_authenticated=True) + self.assertIn('login_required', response['Location']) + + @patch('oidc_provider.views.render') + def test_prompt_consent_parameter(self, render_patched): + """ + Specifies whether the Authorization Server prompts the End-User for reauthentication and consent. + See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest + """ + data = { + 'client_id': self.client.client_id, + 'response_type': self.client.response_type, + 'redirect_uri': self.client.default_redirect_uri, + 'scope': 'openid email', + 'state': self.state, + 'prompt': 'consent' + } + + response = self._auth_request('get', data) + self.assertIn(settings.get('OIDC_LOGIN_URL'), response['Location']) + + response = self._auth_request('get', data, is_user_authenticated=True) + render_patched.assert_called_once() + self.assertTrue(render_patched.call_args[0][1], settings.get('OIDC_TEMPLATES')['authorize']) + + def test_prompt_consent_none_parameter(self): + """ + Specifies whether the Authorization Server prompts the End-User for reauthentication and consent. + See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest + """ + data = { + 'client_id': self.client.client_id, + 'response_type': self.client.response_type, + 'redirect_uri': self.client.default_redirect_uri, + 'scope': 'openid email', + 'state': self.state, + 'prompt': 'consent none' + } + + response = self._auth_request('get', data) + self.assertIn('login_required', response['Location']) + + response = self._auth_request('get', data, is_user_authenticated=True) + self.assertIn('consent_required', response['Location']) + class AuthorizationImplicitFlowTestCase(TestCase, AuthorizeEndpointMixin): diff --git a/oidc_provider/views.py b/oidc_provider/views.py index 7fdbd11..6e03283 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -1,4 +1,5 @@ import logging + try: from urllib import urlencode from urlparse import urlsplit, parse_qs, urlunsplit @@ -10,6 +11,7 @@ from django.contrib.auth.views import ( redirect_to_login, logout, ) +from django.contrib.auth import logout as django_user_logout from django.core.urlresolvers import reverse from django.http import JsonResponse from django.shortcuts import render @@ -44,14 +46,12 @@ from oidc_provider.models import ( from oidc_provider import settings from oidc_provider import signals - logger = logging.getLogger(__name__) OIDC_TEMPLATES = settings.get('OIDC_TEMPLATES') class AuthorizeView(View): - def get(self, request, *args, **kwargs): authorize = AuthorizeEndpoint(request) @@ -67,25 +67,35 @@ class AuthorizeView(View): if hook_resp: return hook_resp - if not authorize.client.require_consent and not (authorize.client.client_type == 'public') \ - and not (authorize.params['prompt'] == 'consent'): - return redirect(authorize.create_response_uri()) + if 'login' in authorize.params['prompt']: + if 'none' in authorize.params['prompt']: + raise AuthorizeError(authorize.params['redirect_uri'], 'login_required', authorize.grant_type) + else: + django_user_logout(request) + return redirect_to_login(request.get_full_path(), settings.get('OIDC_LOGIN_URL')) - if authorize.client.reuse_consent: - # Check if user previously give consent. - if authorize.client_has_user_consent() and not (authorize.client.client_type == 'public') \ - and not (authorize.params['prompt'] == 'consent'): + if 'select_account' in authorize.params['prompt']: + # TODO: see how we can support multiple accounts for the end-user. + if 'none' in authorize.params['prompt']: + raise AuthorizeError(authorize.params['redirect_uri'], 'account_selection_required', authorize.grant_type) + else: + django_user_logout(request) + return redirect_to_login(request.get_full_path(), settings.get('OIDC_LOGIN_URL')) + + if {'none', 'consent'}.issubset(authorize.params['prompt']): + raise AuthorizeError(authorize.params['redirect_uri'], 'consent_required', authorize.grant_type) + + if 'consent' not in authorize.params['prompt']: + if not authorize.client.require_consent and not (authorize.client.client_type == 'public'): return redirect(authorize.create_response_uri()) - if authorize.params['prompt'] == 'none': - raise AuthorizeError(authorize.params['redirect_uri'], 'interaction_required', authorize.grant_type) + if authorize.client.reuse_consent: + # Check if user previously give consent. + if authorize.client_has_user_consent() and not (authorize.client.client_type == 'public'): + return redirect(authorize.create_response_uri()) - if authorize.params['prompt'] == 'login': - return redirect_to_login(request.get_full_path(), settings.get('OIDC_LOGIN_URL')) - - if authorize.params['prompt'] == 'select_account': - # TODO: see how we can support multiple accounts for the end-user. - raise AuthorizeError(authorize.params['redirect_uri'], 'account_selection_required', authorize.grant_type) + if 'none' in authorize.params['prompt']: + raise AuthorizeError(authorize.params['redirect_uri'], 'consent_required', authorize.grant_type) # Generate hidden inputs for the form. context = { @@ -107,7 +117,7 @@ class AuthorizeView(View): return render(request, OIDC_TEMPLATES['authorize'], context) else: - if authorize.params['prompt'] == 'none': + if 'none' in authorize.params['prompt']: raise AuthorizeError(authorize.params['redirect_uri'], 'login_required', authorize.grant_type) return redirect_to_login(request.get_full_path(), settings.get('OIDC_LOGIN_URL')) @@ -120,7 +130,7 @@ class AuthorizeView(View): return render(request, OIDC_TEMPLATES['error'], context) - except (AuthorizeError) as error: + except AuthorizeError as error: uri = error.create_uri( authorize.params['redirect_uri'], authorize.params['state']) @@ -158,7 +168,6 @@ class AuthorizeView(View): class TokenView(View): - def post(self, request, *args, **kwargs): token = TokenEndpoint(request) @@ -206,7 +215,6 @@ def userinfo(request, *args, **kwargs): class ProviderInfoView(View): - def get(self, request, *args, **kwargs): dic = dict() @@ -241,7 +249,6 @@ class ProviderInfoView(View): class JwksView(View): - def get(self, request, *args, **kwargs): dic = dict(keys=[]) @@ -263,7 +270,6 @@ class JwksView(View): class EndSessionView(View): - def get(self, request, *args, **kwargs): id_token_hint = request.GET.get('id_token_hint', '') post_logout_redirect_uri = request.GET.get('post_logout_redirect_uri', '') @@ -302,7 +308,6 @@ class EndSessionView(View): class CheckSessionIframeView(View): - @method_decorator(xframe_options_exempt) def dispatch(self, request, *args, **kwargs): return super(CheckSessionIframeView, self).dispatch(request, *args, **kwargs) From 1215c27d7e792371779c280c38b61fd0dc1774fc Mon Sep 17 00:00:00 2001 From: Jan Brauer Date: Fri, 7 Jul 2017 09:07:21 +0200 Subject: [PATCH 03/71] Redirect URIs must match exactly. (#191) * Test redirect_uri construction This was a test marked as TODO. * Remove duplicate test * Add tests to exactly match redirect URIs * Redirect URIs must match exactly. To quote from the specification at http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest: Redirection URI to which the response will be sent. This URI MUST exactly match one of the Redirection URI values for the Client pre-registered at the OpenID Provider, with the matching performed as described in Section 6.2.1 of [RFC3986] (Simple String Comparison). --- oidc_provider/lib/endpoints/authorize.py | 5 +- oidc_provider/lib/endpoints/token.py | 7 +-- oidc_provider/lib/utils/common.py | 13 ----- .../tests/test_authorize_endpoint.py | 54 ++++++++++++++++++- oidc_provider/tests/test_token_endpoint.py | 47 +++++----------- 5 files changed, 70 insertions(+), 56 deletions(-) diff --git a/oidc_provider/lib/endpoints/authorize.py b/oidc_provider/lib/endpoints/authorize.py index aae16ed..463a35c 100644 --- a/oidc_provider/lib/endpoints/authorize.py +++ b/oidc_provider/lib/endpoints/authorize.py @@ -30,7 +30,7 @@ from oidc_provider.models import ( UserConsent, ) from oidc_provider import settings -from oidc_provider.lib.utils.common import cleanup_url_from_query_string, get_browser_state_or_default +from oidc_provider.lib.utils.common import get_browser_state_or_default logger = logging.getLogger(__name__) @@ -93,8 +93,7 @@ class AuthorizeEndpoint(object): if self.is_authentication and not self.params['redirect_uri']: logger.debug('[Authorize] Missing redirect uri.') raise RedirectUriError() - clean_redirect_uri = cleanup_url_from_query_string(self.params['redirect_uri']) - if not (clean_redirect_uri in self.client.redirect_uris): + if not (self.params['redirect_uri'] in self.client.redirect_uris): logger.debug('[Authorize] Invalid redirect uri: %s', self.params['redirect_uri']) raise RedirectUriError() diff --git a/oidc_provider/lib/endpoints/token.py b/oidc_provider/lib/endpoints/token.py index ae1eb98..08597c5 100644 --- a/oidc_provider/lib/endpoints/token.py +++ b/oidc_provider/lib/endpoints/token.py @@ -3,7 +3,6 @@ import hashlib import logging import re from django.contrib.auth import authenticate -from oidc_provider.lib.utils.common import cleanup_url_from_query_string try: from urllib.parse import unquote @@ -43,8 +42,7 @@ class TokenEndpoint(object): self.params['client_id'] = client_id self.params['client_secret'] = client_secret - self.params['redirect_uri'] = unquote( - self.request.POST.get('redirect_uri', '').split('?', 1)[0]) + self.params['redirect_uri'] = self.request.POST.get('redirect_uri', '') self.params['grant_type'] = self.request.POST.get('grant_type', '') self.params['code'] = self.request.POST.get('code', '') self.params['state'] = self.request.POST.get('state', '') @@ -93,8 +91,7 @@ class TokenEndpoint(object): raise TokenError('invalid_client') if self.params['grant_type'] == 'authorization_code': - clean_redirect_uri = cleanup_url_from_query_string(self.params['redirect_uri']) - if not (clean_redirect_uri in self.client.redirect_uris): + if not (self.params['redirect_uri'] in self.client.redirect_uris): logger.debug('[Token] Invalid redirect uri: %s', self.params['redirect_uri']) raise TokenError('invalid_client') diff --git a/oidc_provider/lib/utils/common.py b/oidc_provider/lib/utils/common.py index c4778bd..09bdf00 100644 --- a/oidc_provider/lib/utils/common.py +++ b/oidc_provider/lib/utils/common.py @@ -11,19 +11,6 @@ except ImportError: from urllib.parse import urlsplit, urlunsplit -def cleanup_url_from_query_string(uri): - """ - Function used to clean up the uri from any query string, used i.e. by endpoints to validate redirect_uri - - :param uri: URI to clean from query string - :type uri: str - :return: cleaned URI without query string - """ - clean_uri = urlsplit(uri) - clean_uri = urlunsplit(clean_uri._replace(query='')) - return clean_uri - - def redirect(uri): """ Custom Response object for redirecting to a Non-HTTP url scheme. diff --git a/oidc_provider/tests/test_authorize_endpoint.py b/oidc_provider/tests/test_authorize_endpoint.py index af104f0..e03644e 100644 --- a/oidc_provider/tests/test_authorize_endpoint.py +++ b/oidc_provider/tests/test_authorize_endpoint.py @@ -1,3 +1,5 @@ +from oidc_provider.lib.errors import RedirectUriError + try: from urllib.parse import urlencode except ImportError: @@ -249,9 +251,13 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): self.assertEqual(is_code_ok, True, msg='Code returned is invalid or missing.') def test_response_uri_is_properly_constructed(self): + """ + Check that the redirect_uri matches the one configured for the client. + Only 'state' and 'code' should be appended. + """ data = { 'client_id': self.client.client_id, - 'redirect_uri': self.client.default_redirect_uri + "?redirect_state=xyz", + 'redirect_uri': self.client.default_redirect_uri, 'response_type': 'code', 'scope': 'openid email', 'state': self.state, @@ -260,7 +266,51 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): response = self._auth_request('post', data, is_user_authenticated=True) - # TODO + parsed = urlsplit(response['Location']) + params = parse_qs(parsed.query or parsed.fragment) + state = params['state'][0] + self.assertEquals(self.state, state, msg="State returned is invalid or missing") + + is_code_ok = is_code_valid(url=response['Location'], + user=self.user, + client=self.client) + self.assertTrue(is_code_ok, msg='Code returned is invalid or missing') + + self.assertEquals(set(params.keys()), set(['state', 'code']), msg='More than state or code appended as query params') + + self.assertTrue(response['Location'].startswith(self.client.default_redirect_uri), msg='Different redirect_uri returned') + + def test_unknown_redirect_uris_are_rejected(self): + """ + If a redirect_uri is not registered with the client the request must be rejected. + See http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest. + """ + data = { + 'client_id': self.client.client_id, + 'response_type': 'code', + 'redirect_uri': 'http://neverseenthis.com', + 'scope': 'openid email', + 'state': self.state, + } + + response = self._auth_request('get', data) + self.assertIn(RedirectUriError.error, response.content.decode('utf-8'), msg='No redirect_uri error') + + def test_manipulated_redirect_uris_are_rejected(self): + """ + If a redirect_uri does not exactly match the registered uri it must be rejected. + See http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest. + """ + data = { + 'client_id': self.client.client_id, + 'response_type': 'code', + 'redirect_uri': self.client.default_redirect_uri + "?some=query", + 'scope': 'openid email', + 'state': self.state, + } + + response = self._auth_request('get', data) + self.assertIn(RedirectUriError.error, response.content.decode('utf-8'), msg='No redirect_uri error') def test_public_client_auto_approval(self): """ diff --git a/oidc_provider/tests/test_token_endpoint.py b/oidc_provider/tests/test_token_endpoint.py index 46e96e4..8215558 100644 --- a/oidc_provider/tests/test_token_endpoint.py +++ b/oidc_provider/tests/test_token_endpoint.py @@ -339,12 +339,12 @@ class TokenTestCase(TestCase): response = self._post_request(post_data) self.assertIn('invalid_grant', response.content.decode('utf-8')) - def test_client_redirect_url(self): + def test_client_redirect_uri(self): """ - Validate that client redirect URIs with query strings match registered - URIs, and that unregistered URIs are rejected. - - source: https://github.com/jerrykan/django-oidc-provider/blob/2f54e537666c689dd8448f8bbc6a3a0244b01a97/oidc_provider/tests/test_token_endpoint.py + Validate that client redirect URIs exactly match registered + URIs, and that unregistered URIs or URIs with query parameters are rejected. + See http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest and + http://openid.net/specs/openid-connect-core-1_0.html#HybridTokenRequest. """ SIGKEYS = self._get_keys() code = self._create_code() @@ -354,15 +354,19 @@ class TokenTestCase(TestCase): post_data['redirect_uri'] = 'http://invalid.example.org' response = self._post_request(post_data) + self.assertIn('invalid_client', response.content.decode('utf-8')) - self.assertIn('invalid_client', response.content.decode('utf-8')), - - # Registered URI contained a query string - post_data['redirect_uri'] = 'http://example.com/?client=OidcClient' + # Registered URI, but with query string appended + post_data['redirect_uri'] = self.client.default_redirect_uri + '?foo=bar' response = self._post_request(post_data) + self.assertIn('invalid_client', response.content.decode('utf-8')) - self.assertNotIn('invalid_client', response.content.decode('utf-8')), + # Registered URI + post_data['redirect_uri'] = self.client.default_redirect_uri + + response = self._post_request(post_data) + self.assertNotIn('invalid_client', response.content.decode('utf-8')) def test_request_methods(self): """ @@ -440,29 +444,6 @@ class TokenTestCase(TestCase): False, msg='Client authentication fails using HTTP Basic Auth.') - def test_client_redirect_url(self): - """ - Validate that client redirect URIs with query strings match registered - URIs, and that unregistered URIs are rejected. - """ - SIGKEYS = self._get_keys() - code = self._create_code() - post_data = self._auth_code_post_data(code=code.code) - - # Unregistered URI - post_data['redirect_uri'] = 'http://invalid.example.org' - - response = self._post_request(post_data) - - self.assertIn('invalid_client', response.content.decode('utf-8')), - - # Registered URI contained a query string - post_data['redirect_uri'] = 'http://example.com/?client=OidcClient' - - response = self._post_request(post_data) - - self.assertNotIn('invalid_client', response.content.decode('utf-8')), - def test_access_token_contains_nonce(self): """ If present in the Authentication Request, Authorization Servers MUST From 5165312d014e2df127aa9591c57a24b2044e87fd Mon Sep 17 00:00:00 2001 From: Tuomas Suutari Date: Fri, 7 Jul 2017 14:18:36 +0300 Subject: [PATCH 04/71] Use stored user consent for public clients too (#189) When using Implicit Flow, it should be OK to use the stored user consent even if the client is public. The redirect uri checks should make sure that the stored consent of another client cannot be misused to get a consent to a site that is not related to the client. It is also important to support this, since public clients using Implicit Flow do not have a refresh token to update their access tokens, so only way to keep their login session open is by issuing authorization requests from an iframe with the "prompt=none" parameter (which does not work without the previously stored consent). See the following links for more info and examples on how to renew the access token with SPAs: https://auth0.com/docs/api-auth/tutorials/silent-authentication#refresh-expired-tokens https://damienbod.com/2017/06/02/ https://github.com/IdentityServer/IdentityServer3/issues/719#issuecomment-230145034 --- .../tests/test_authorize_endpoint.py | 27 ++++++++++++++++++- oidc_provider/views.py | 24 +++++++++++------ 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/oidc_provider/tests/test_authorize_endpoint.py b/oidc_provider/tests/test_authorize_endpoint.py index e03644e..41fbb19 100644 --- a/oidc_provider/tests/test_authorize_endpoint.py +++ b/oidc_provider/tests/test_authorize_endpoint.py @@ -314,7 +314,7 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): def test_public_client_auto_approval(self): """ - It's recommended not auto-approving requests for non-confidential clients. + It's recommended not auto-approving requests for non-confidential clients using Authorization Code. """ data = { 'client_id': self.client_public_with_no_consent.client_id, @@ -449,6 +449,9 @@ class AuthorizationImplicitFlowTestCase(TestCase, AuthorizeEndpointMixin): self.user = create_fake_user() self.client = create_fake_client(response_type='id_token token') self.client_public = create_fake_client(response_type='id_token token', is_public=True) + self.client_public_no_consent = create_fake_client( + response_type='id_token token', is_public=True, + require_consent=False) self.client_no_access = create_fake_client(response_type='id_token') self.client_public_no_access = create_fake_client(response_type='id_token', is_public=True) self.state = uuid.uuid4().hex @@ -582,6 +585,28 @@ class AuthorizationImplicitFlowTestCase(TestCase, AuthorizeEndpointMixin): self.assertNotIn('at_hash', id_token) + def test_public_client_implicit_auto_approval(self): + """ + Public clients using Implicit Flow should be able to reuse consent. + """ + data = { + 'client_id': self.client_public_no_consent.client_id, + 'response_type': self.client_public_no_consent.response_type, + 'redirect_uri': self.client_public_no_consent.default_redirect_uri, + 'scope': 'openid email', + 'state': self.state, + 'nonce': self.nonce, + } + + response = self._auth_request('get', data, is_user_authenticated=True) + response_text = response.content.decode('utf-8') + self.assertEquals(response_text, '') + components = urlsplit(response['Location']) + fragment = parse_qs(components[4]) + self.assertIn('access_token', fragment) + self.assertIn('id_token', fragment) + self.assertIn('expires_in', fragment) + class AuthorizationHybridFlowTestCase(TestCase, AuthorizeEndpointMixin): """ diff --git a/oidc_provider/views.py b/oidc_provider/views.py index 6e03283..a5f8cc6 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -66,7 +66,7 @@ class AuthorizeView(View): client=authorize.client) if hook_resp: return hook_resp - + if 'login' in authorize.params['prompt']: if 'none' in authorize.params['prompt']: raise AuthorizeError(authorize.params['redirect_uri'], 'login_required', authorize.grant_type) @@ -85,14 +85,22 @@ class AuthorizeView(View): if {'none', 'consent'}.issubset(authorize.params['prompt']): raise AuthorizeError(authorize.params['redirect_uri'], 'consent_required', authorize.grant_type) - if 'consent' not in authorize.params['prompt']: - if not authorize.client.require_consent and not (authorize.client.client_type == 'public'): - return redirect(authorize.create_response_uri()) + implicit_flow_resp_types = set(['id_token', 'id_token token']) + allow_skipping_consent = ( + authorize.client.client_type != 'public' or + authorize.client.response_type in implicit_flow_resp_types) - if authorize.client.reuse_consent: - # Check if user previously give consent. - if authorize.client_has_user_consent() and not (authorize.client.client_type == 'public'): - return redirect(authorize.create_response_uri()) + if not authorize.client.require_consent and ( + allow_skipping_consent and + 'consent' not in authorize.params['prompt']): + return redirect(authorize.create_response_uri()) + + if authorize.client.reuse_consent: + # Check if user previously give consent. + if authorize.client_has_user_consent() and ( + allow_skipping_consent and + 'consent' not in authorize.params['prompt']): + return redirect(authorize.create_response_uri()) if 'none' in authorize.params['prompt']: raise AuthorizeError(authorize.params['redirect_uri'], 'consent_required', authorize.grant_type) From eed581399e6c228dd57135dc599cefe6ab67b550 Mon Sep 17 00:00:00 2001 From: Wojciech Bartosiak Date: Fri, 7 Jul 2017 17:47:11 +0200 Subject: [PATCH 05/71] Fixes #192 --- docs/sections/scopesclaims.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sections/scopesclaims.rst b/docs/sections/scopesclaims.rst index 58481f3..e0a283e 100644 --- a/docs/sections/scopesclaims.rst +++ b/docs/sections/scopesclaims.rst @@ -78,7 +78,7 @@ Let's say that you want add your custom ``foo`` scope for your OAuth2/OpenID pro Somewhere in your Django ``settings.py``:: - OIDC_USERINFO = 'yourproject.oidc_provider_settings.CustomScopeClaims' + OIDC_EXTRA_SCOPE_CLAIMS = 'yourproject.oidc_provider_settings.CustomScopeClaims' Inside your oidc_provider_settings.py file add the following class:: From ea340993b1942b4f966a9a79f61110c2bbe7ea42 Mon Sep 17 00:00:00 2001 From: Tuomas Suutari Date: Mon, 10 Jul 2017 18:48:12 +0300 Subject: [PATCH 06/71] Fix scope handling of token endpoint (#193) 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 08597c5..abba70a 100644 --- a/oidc_provider/lib/endpoints/token.py +++ b/oidc_provider/lib/endpoints/token.py @@ -159,6 +159,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, @@ -170,7 +172,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 @@ -185,6 +187,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, @@ -197,7 +201,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 = {} @@ -220,10 +224,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: @@ -233,7 +245,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 8215558..b23d4fd 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 7407e2c5b09736bb905ca2d817454cb4157e1c18 Mon Sep 17 00:00:00 2001 From: Wojciech Bartosiak Date: Tue, 11 Jul 2017 07:29:24 +0200 Subject: [PATCH 07/71] Bump version --- CHANGELOG.md | 14 ++++++++++++++ docs/conf.py | 6 +++--- docs/sections/installation.rst | 4 ++-- setup.py | 2 +- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f572a6d..a21dc8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file. ### [Unreleased] +### [0.5.1] - 2017-07-11 + +##### Changed +- Documentation template to `Read The Docs`. + +##### Fixed +- `install_requires` has not longer pinned versions. +- Removed infinity loop during authorization przez `prompt=login` has been send. +- Changed `prompt` handling as set of options instead of regular string. +- Redirect URIs must match exactly with query params. +- Stored user consent are usefull for public clients too. +- Fixed documentation for custom scopes handling. +- Scopes during refresh and code exchange are being taken from authorization request and not from query params + ### [0.5.0] - 2017-05-18 ##### Added diff --git a/docs/conf.py b/docs/conf.py index e3760f6..1d0828f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -53,9 +53,9 @@ author = u'Juan Ignacio Fiorentino' # built documents. # # The short X.Y version. -version = u'0.3' +version = u'0.5' # The full version, including alpha/beta/rc tags. -release = u'0.3.x' +release = u'0.5.x' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -106,7 +106,7 @@ todo_include_todos = False # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'alabaster' +html_theme = 'sphinx_rtd_theme' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the diff --git a/docs/sections/installation.rst b/docs/sections/installation.rst index 53db3ed..ebfe61e 100644 --- a/docs/sections/installation.rst +++ b/docs/sections/installation.rst @@ -6,8 +6,8 @@ Installation Requirements ============ -* Python: ``2.7`` ``3.4`` ``3.5`` -* Django: ``1.7`` ``1.8`` ``1.9`` ``1.10`` +* Python: ``2.7`` ``3.4`` ``3.5`` ``3.6`` +* Django: ``1.7`` ``1.8`` ``1.9`` ``1.10`` ``1.11`` Quick Installation ================== diff --git a/setup.py b/setup.py index df451ac..cd0abc6 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) setup( name='django-oidc-provider', - version='0.5.0', + version='0.5.1', packages=find_packages(), include_package_data=True, license='MIT License', From 2e1efc41ed6f00b511e1a6b79127d3d60143e32c Mon Sep 17 00:00:00 2001 From: Wojciech Bartosiak Date: Tue, 11 Jul 2017 16:44:24 +0200 Subject: [PATCH 08/71] fixed typos --- CHANGELOG.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a21dc8b..9656603 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,16 +7,16 @@ All notable changes to this project will be documented in this file. ### [0.5.1] - 2017-07-11 ##### Changed -- Documentation template to `Read The Docs`. +- Documentation template changed to `Read The Docs`. ##### Fixed - `install_requires` has not longer pinned versions. -- Removed infinity loop during authorization przez `prompt=login` has been send. +- Removed infinity loop during authorization stage when `prompt=login` has been send. - Changed `prompt` handling as set of options instead of regular string. -- Redirect URIs must match exactly with query params. -- Stored user consent are usefull for public clients too. +- Redirect URI must match exactly with given in query parameter. +- Stored user consent are useful for public clients too. - Fixed documentation for custom scopes handling. -- Scopes during refresh and code exchange are being taken from authorization request and not from query params +- Scopes during refresh and code exchange are being taken from authorization request and not from query parameters. ### [0.5.0] - 2017-05-18 From f78e2be3c548e22cc5310eb39386f0f2b07074c7 Mon Sep 17 00:00:00 2001 From: Jan Brauer Date: Wed, 19 Jul 2017 10:52:10 +0200 Subject: [PATCH 09/71] Fix infinite login loop if "prompt=login" (#198) * Add test to expose issue #197 * Strip 'login' from prompt before redirecting This fixes #197. Otherwise the user would have to login once, then is immediately logged out and prompted to login again. * Only remove 'login' if present * Don't append an empty prompt parameter * Inline variable --- .../tests/test_authorize_endpoint.py | 14 ++++++++++-- oidc_provider/views.py | 22 +++++++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/oidc_provider/tests/test_authorize_endpoint.py b/oidc_provider/tests/test_authorize_endpoint.py index 41fbb19..361f27f 100644 --- a/oidc_provider/tests/test_authorize_endpoint.py +++ b/oidc_provider/tests/test_authorize_endpoint.py @@ -1,9 +1,9 @@ from oidc_provider.lib.errors import RedirectUriError try: - from urllib.parse import urlencode + from urllib.parse import urlencode, quote except ImportError: - from urllib import urlencode + from urllib import urlencode, quote try: from urllib.parse import parse_qs, urlsplit except ImportError: @@ -369,10 +369,20 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): response = self._auth_request('get', data) self.assertIn(settings.get('OIDC_LOGIN_URL'), response['Location']) + self.assertNotIn( + quote('prompt=login'), + response['Location'], + "Found prompt=login, this leads to infinite login loop. See https://github.com/juanifioren/django-oidc-provider/issues/197." + ) response = self._auth_request('get', data, is_user_authenticated=True) self.assertIn(settings.get('OIDC_LOGIN_URL'), response['Location']) self.assertTrue(logout_function.called_once()) + self.assertNotIn( + quote('prompt=login'), + response['Location'], + "Found prompt=login, this leads to infinite login loop. See https://github.com/juanifioren/django-oidc-provider/issues/197." + ) def test_prompt_login_none_parameter(self): """ diff --git a/oidc_provider/views.py b/oidc_provider/views.py index a5f8cc6..27d1499 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -66,13 +66,14 @@ class AuthorizeView(View): client=authorize.client) if hook_resp: return hook_resp - + if 'login' in authorize.params['prompt']: if 'none' in authorize.params['prompt']: raise AuthorizeError(authorize.params['redirect_uri'], 'login_required', authorize.grant_type) else: django_user_logout(request) - return redirect_to_login(request.get_full_path(), settings.get('OIDC_LOGIN_URL')) + next_page = self.strip_prompt_login(request.get_full_path()) + return redirect_to_login(next_page, settings.get('OIDC_LOGIN_URL')) if 'select_account' in authorize.params['prompt']: # TODO: see how we can support multiple accounts for the end-user. @@ -127,6 +128,9 @@ class AuthorizeView(View): else: if 'none' in authorize.params['prompt']: raise AuthorizeError(authorize.params['redirect_uri'], 'login_required', authorize.grant_type) + if 'login' in authorize.params['prompt']: + next_page = self.strip_prompt_login(request.get_full_path()) + return redirect_to_login(next_page, settings.get('OIDC_LOGIN_URL')) return redirect_to_login(request.get_full_path(), settings.get('OIDC_LOGIN_URL')) @@ -174,6 +178,20 @@ class AuthorizeView(View): return redirect(uri) + @staticmethod + def strip_prompt_login(path): + """ + Strips 'login' from the 'prompt' query parameter. + """ + uri = urlsplit(path) + query_params = parse_qs(uri.query) + if 'login' in query_params['prompt']: + query_params['prompt'].remove('login') + if not query_params['prompt']: + del query_params['prompt'] + uri = uri._replace(query=urlencode(query_params, doseq=True)) + return urlunsplit(uri) + class TokenView(View): def post(self, request, *args, **kwargs): From 04c03787afc3b77ce8a4edefbefa32dc4b34fde0 Mon Sep 17 00:00:00 2001 From: Dillon Dixon Date: Thu, 10 Aug 2017 02:38:50 -0700 Subject: [PATCH 10/71] Fix Django 2.0 deprecation warnings (#185) * explicit default foreign key delete operations * first iteration of Django 2.0 deprecation fixes --- oidc_provider/lib/utils/common.py | 8 +++++++- oidc_provider/migrations/0001_initial.py | 10 +++++----- oidc_provider/migrations/0002_userconsent.py | 4 ++-- oidc_provider/models.py | 4 ++-- oidc_provider/urls.py | 2 +- oidc_provider/views.py | 8 +++++++- 6 files changed, 24 insertions(+), 12 deletions(-) diff --git a/oidc_provider/lib/utils/common.py b/oidc_provider/lib/utils/common.py index 09bdf00..ee1bfa4 100644 --- a/oidc_provider/lib/utils/common.py +++ b/oidc_provider/lib/utils/common.py @@ -1,6 +1,12 @@ from hashlib import sha224 -from django.core.urlresolvers import reverse +import django + +if django.VERSION >= (1, 11): + from django.urls import reverse +else: + from django.core.urlresolvers import reverse + from django.http import HttpResponse from oidc_provider import settings diff --git a/oidc_provider/migrations/0001_initial.py b/oidc_provider/migrations/0001_initial.py index ca32b7e..913f47d 100644 --- a/oidc_provider/migrations/0001_initial.py +++ b/oidc_provider/migrations/0001_initial.py @@ -34,7 +34,7 @@ class Migration(migrations.Migration): ('expires_at', models.DateTimeField()), ('_scope', models.TextField(default=b'')), ('code', models.CharField(unique=True, max_length=255)), - ('client', models.ForeignKey(to='oidc_provider.Client')), + ('client', models.ForeignKey(to='oidc_provider.Client', on_delete=models.CASCADE)), ], options={ 'abstract': False, @@ -49,7 +49,7 @@ class Migration(migrations.Migration): ('_scope', models.TextField(default=b'')), ('access_token', models.CharField(unique=True, max_length=255)), ('_id_token', models.TextField()), - ('client', models.ForeignKey(to='oidc_provider.Client')), + ('client', models.ForeignKey(to='oidc_provider.Client', on_delete=models.CASCADE)), ], options={ 'abstract': False, @@ -59,7 +59,7 @@ class Migration(migrations.Migration): migrations.CreateModel( name='UserInfo', fields=[ - ('user', models.OneToOneField(primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)), + ('user', models.OneToOneField(primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), ('given_name', models.CharField(max_length=255, null=True, blank=True)), ('family_name', models.CharField(max_length=255, null=True, blank=True)), ('middle_name', models.CharField(max_length=255, null=True, blank=True)), @@ -89,13 +89,13 @@ class Migration(migrations.Migration): migrations.AddField( model_name='token', name='user', - field=models.ForeignKey(to=settings.AUTH_USER_MODEL), + field=models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE), preserve_default=True, ), migrations.AddField( model_name='code', name='user', - field=models.ForeignKey(to=settings.AUTH_USER_MODEL), + field=models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE), preserve_default=True, ), ] diff --git a/oidc_provider/migrations/0002_userconsent.py b/oidc_provider/migrations/0002_userconsent.py index 4cdf6e3..d2a0f12 100644 --- a/oidc_provider/migrations/0002_userconsent.py +++ b/oidc_provider/migrations/0002_userconsent.py @@ -19,8 +19,8 @@ class Migration(migrations.Migration): ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('expires_at', models.DateTimeField()), ('_scope', models.TextField(default=b'')), - ('client', models.ForeignKey(to='oidc_provider.Client')), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), + ('client', models.ForeignKey(to='oidc_provider.Client', on_delete=models.CASCADE)), + ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), ], options={ 'abstract': False, diff --git a/oidc_provider/models.py b/oidc_provider/models.py index a196239..cfee5d5 100644 --- a/oidc_provider/models.py +++ b/oidc_provider/models.py @@ -83,8 +83,8 @@ class Client(models.Model): class BaseCodeTokenModel(models.Model): - user = models.ForeignKey(settings.AUTH_USER_MODEL, verbose_name=_(u'User')) - client = models.ForeignKey(Client, verbose_name=_(u'Client')) + user = models.ForeignKey(settings.AUTH_USER_MODEL, verbose_name=_(u'User'), on_delete=models.CASCADE) + client = models.ForeignKey(Client, verbose_name=_(u'Client'), on_delete=models.CASCADE) expires_at = models.DateTimeField(verbose_name=_(u'Expiration Date')) _scope = models.TextField(default='', verbose_name=_(u'Scopes')) diff --git a/oidc_provider/urls.py b/oidc_provider/urls.py index 6b62883..d501e88 100644 --- a/oidc_provider/urls.py +++ b/oidc_provider/urls.py @@ -6,7 +6,7 @@ from oidc_provider import ( views, ) - +app_name = 'oidc_provider' urlpatterns = [ url(r'^authorize/?$', views.AuthorizeView.as_view(), name='authorize'), url(r'^token/?$', csrf_exempt(views.TokenView.as_view()), name='token'), diff --git a/oidc_provider/views.py b/oidc_provider/views.py index 27d1499..f1b90f0 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -11,8 +11,14 @@ 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 + from django.contrib.auth import logout as django_user_logout -from django.core.urlresolvers import reverse from django.http import JsonResponse from django.shortcuts import render from django.template.loader import render_to_string From 8e26248022e026cf4911b8ecaa330ac492d42478 Mon Sep 17 00:00:00 2001 From: Wojciech Bartosiak Date: Tue, 22 Aug 2017 17:33:13 +0200 Subject: [PATCH 11/71] Preparing v0.5.2 (#201) * Fix infinite login loop if "prompt=login" (#198) * Fix Django 2.0 deprecation warnings (#185) --- oidc_provider/lib/utils/common.py | 8 ++++- oidc_provider/migrations/0001_initial.py | 10 +++---- oidc_provider/migrations/0002_userconsent.py | 4 +-- oidc_provider/models.py | 4 +-- .../tests/test_authorize_endpoint.py | 14 +++++++-- oidc_provider/urls.py | 2 +- oidc_provider/views.py | 30 +++++++++++++++++-- 7 files changed, 56 insertions(+), 16 deletions(-) diff --git a/oidc_provider/lib/utils/common.py b/oidc_provider/lib/utils/common.py index 09bdf00..ee1bfa4 100644 --- a/oidc_provider/lib/utils/common.py +++ b/oidc_provider/lib/utils/common.py @@ -1,6 +1,12 @@ from hashlib import sha224 -from django.core.urlresolvers import reverse +import django + +if django.VERSION >= (1, 11): + from django.urls import reverse +else: + from django.core.urlresolvers import reverse + from django.http import HttpResponse from oidc_provider import settings diff --git a/oidc_provider/migrations/0001_initial.py b/oidc_provider/migrations/0001_initial.py index ca32b7e..913f47d 100644 --- a/oidc_provider/migrations/0001_initial.py +++ b/oidc_provider/migrations/0001_initial.py @@ -34,7 +34,7 @@ class Migration(migrations.Migration): ('expires_at', models.DateTimeField()), ('_scope', models.TextField(default=b'')), ('code', models.CharField(unique=True, max_length=255)), - ('client', models.ForeignKey(to='oidc_provider.Client')), + ('client', models.ForeignKey(to='oidc_provider.Client', on_delete=models.CASCADE)), ], options={ 'abstract': False, @@ -49,7 +49,7 @@ class Migration(migrations.Migration): ('_scope', models.TextField(default=b'')), ('access_token', models.CharField(unique=True, max_length=255)), ('_id_token', models.TextField()), - ('client', models.ForeignKey(to='oidc_provider.Client')), + ('client', models.ForeignKey(to='oidc_provider.Client', on_delete=models.CASCADE)), ], options={ 'abstract': False, @@ -59,7 +59,7 @@ class Migration(migrations.Migration): migrations.CreateModel( name='UserInfo', fields=[ - ('user', models.OneToOneField(primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)), + ('user', models.OneToOneField(primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), ('given_name', models.CharField(max_length=255, null=True, blank=True)), ('family_name', models.CharField(max_length=255, null=True, blank=True)), ('middle_name', models.CharField(max_length=255, null=True, blank=True)), @@ -89,13 +89,13 @@ class Migration(migrations.Migration): migrations.AddField( model_name='token', name='user', - field=models.ForeignKey(to=settings.AUTH_USER_MODEL), + field=models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE), preserve_default=True, ), migrations.AddField( model_name='code', name='user', - field=models.ForeignKey(to=settings.AUTH_USER_MODEL), + field=models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE), preserve_default=True, ), ] diff --git a/oidc_provider/migrations/0002_userconsent.py b/oidc_provider/migrations/0002_userconsent.py index 4cdf6e3..d2a0f12 100644 --- a/oidc_provider/migrations/0002_userconsent.py +++ b/oidc_provider/migrations/0002_userconsent.py @@ -19,8 +19,8 @@ class Migration(migrations.Migration): ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('expires_at', models.DateTimeField()), ('_scope', models.TextField(default=b'')), - ('client', models.ForeignKey(to='oidc_provider.Client')), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), + ('client', models.ForeignKey(to='oidc_provider.Client', on_delete=models.CASCADE)), + ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), ], options={ 'abstract': False, diff --git a/oidc_provider/models.py b/oidc_provider/models.py index a196239..cfee5d5 100644 --- a/oidc_provider/models.py +++ b/oidc_provider/models.py @@ -83,8 +83,8 @@ class Client(models.Model): class BaseCodeTokenModel(models.Model): - user = models.ForeignKey(settings.AUTH_USER_MODEL, verbose_name=_(u'User')) - client = models.ForeignKey(Client, verbose_name=_(u'Client')) + user = models.ForeignKey(settings.AUTH_USER_MODEL, verbose_name=_(u'User'), on_delete=models.CASCADE) + client = models.ForeignKey(Client, verbose_name=_(u'Client'), on_delete=models.CASCADE) expires_at = models.DateTimeField(verbose_name=_(u'Expiration Date')) _scope = models.TextField(default='', verbose_name=_(u'Scopes')) diff --git a/oidc_provider/tests/test_authorize_endpoint.py b/oidc_provider/tests/test_authorize_endpoint.py index 41fbb19..361f27f 100644 --- a/oidc_provider/tests/test_authorize_endpoint.py +++ b/oidc_provider/tests/test_authorize_endpoint.py @@ -1,9 +1,9 @@ from oidc_provider.lib.errors import RedirectUriError try: - from urllib.parse import urlencode + from urllib.parse import urlencode, quote except ImportError: - from urllib import urlencode + from urllib import urlencode, quote try: from urllib.parse import parse_qs, urlsplit except ImportError: @@ -369,10 +369,20 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): response = self._auth_request('get', data) self.assertIn(settings.get('OIDC_LOGIN_URL'), response['Location']) + self.assertNotIn( + quote('prompt=login'), + response['Location'], + "Found prompt=login, this leads to infinite login loop. See https://github.com/juanifioren/django-oidc-provider/issues/197." + ) response = self._auth_request('get', data, is_user_authenticated=True) self.assertIn(settings.get('OIDC_LOGIN_URL'), response['Location']) self.assertTrue(logout_function.called_once()) + self.assertNotIn( + quote('prompt=login'), + response['Location'], + "Found prompt=login, this leads to infinite login loop. See https://github.com/juanifioren/django-oidc-provider/issues/197." + ) def test_prompt_login_none_parameter(self): """ diff --git a/oidc_provider/urls.py b/oidc_provider/urls.py index 6b62883..d501e88 100644 --- a/oidc_provider/urls.py +++ b/oidc_provider/urls.py @@ -6,7 +6,7 @@ from oidc_provider import ( views, ) - +app_name = 'oidc_provider' urlpatterns = [ url(r'^authorize/?$', views.AuthorizeView.as_view(), name='authorize'), url(r'^token/?$', csrf_exempt(views.TokenView.as_view()), name='token'), diff --git a/oidc_provider/views.py b/oidc_provider/views.py index a5f8cc6..f1b90f0 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -11,8 +11,14 @@ 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 + from django.contrib.auth import logout as django_user_logout -from django.core.urlresolvers import reverse from django.http import JsonResponse from django.shortcuts import render from django.template.loader import render_to_string @@ -66,13 +72,14 @@ class AuthorizeView(View): client=authorize.client) if hook_resp: return hook_resp - + if 'login' in authorize.params['prompt']: if 'none' in authorize.params['prompt']: raise AuthorizeError(authorize.params['redirect_uri'], 'login_required', authorize.grant_type) else: django_user_logout(request) - return redirect_to_login(request.get_full_path(), settings.get('OIDC_LOGIN_URL')) + next_page = self.strip_prompt_login(request.get_full_path()) + return redirect_to_login(next_page, settings.get('OIDC_LOGIN_URL')) if 'select_account' in authorize.params['prompt']: # TODO: see how we can support multiple accounts for the end-user. @@ -127,6 +134,9 @@ class AuthorizeView(View): else: if 'none' in authorize.params['prompt']: raise AuthorizeError(authorize.params['redirect_uri'], 'login_required', authorize.grant_type) + if 'login' in authorize.params['prompt']: + next_page = self.strip_prompt_login(request.get_full_path()) + return redirect_to_login(next_page, settings.get('OIDC_LOGIN_URL')) return redirect_to_login(request.get_full_path(), settings.get('OIDC_LOGIN_URL')) @@ -174,6 +184,20 @@ class AuthorizeView(View): return redirect(uri) + @staticmethod + def strip_prompt_login(path): + """ + Strips 'login' from the 'prompt' query parameter. + """ + uri = urlsplit(path) + query_params = parse_qs(uri.query) + if 'login' in query_params['prompt']: + query_params['prompt'].remove('login') + if not query_params['prompt']: + del query_params['prompt'] + uri = uri._replace(query=urlencode(query_params, doseq=True)) + return urlunsplit(uri) + class TokenView(View): def post(self, request, *args, **kwargs): From 0e4ba169dfb27b2d40d0e3fc0a0a9812e3eafe9a Mon Sep 17 00:00:00 2001 From: Wojciech Bartosiak Date: Tue, 22 Aug 2017 17:36:18 +0200 Subject: [PATCH 12/71] Update CHANGELOG.md --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9656603..c0951b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. ### [Unreleased] + +### [0.5.2] - 2017-08-22 + +##### Fixed +- Fix infinite login loop if "prompt=login" (#198) +- Fix Django 2.0 deprecation warnings (#185) + + ### [0.5.1] - 2017-07-11 ##### Changed From f052f694c9602102ccd9e693b5d54b30e1ebf69f Mon Sep 17 00:00:00 2001 From: Wojciech Bartosiak Date: Tue, 22 Aug 2017 17:36:54 +0200 Subject: [PATCH 13/71] Bump version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index cd0abc6..61ce8cc 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) setup( name='django-oidc-provider', - version='0.5.1', + version='0.5.2', packages=find_packages(), include_package_data=True, license='MIT License', From 5dcd6a10b00e08522e82802668dd521c635758ec Mon Sep 17 00:00:00 2001 From: Philippe Savoie Date: Tue, 8 Aug 2017 15:41:42 -0700 Subject: [PATCH 14/71] Add pep8 compliance and checker --- docs/conf.py | 142 +++++++++--------- example_project/myapp/urls.py | 4 +- example_project/myapp/wsgi.py | 3 +- oidc_provider/admin.py | 4 +- oidc_provider/lib/claims.py | 12 +- oidc_provider/lib/endpoints/authorize.py | 13 +- oidc_provider/lib/endpoints/token.py | 5 - oidc_provider/lib/errors.py | 1 + oidc_provider/lib/utils/common.py | 11 +- oidc_provider/lib/utils/oauth2.py | 10 +- oidc_provider/lib/utils/token.py | 10 +- .../management/commands/creatersakey.py | 3 - oidc_provider/migrations/0001_initial.py | 4 +- .../migrations/0004_remove_userinfo.py | 2 +- .../migrations/0007_auto_20160111_1844.py | 3 +- .../migrations/0011_client_client_type.py | 7 +- .../migrations/0014_client_jwt_alg.py | 6 +- .../migrations/0015_change_client_code.py | 19 ++- .../0016_userconsent_and_verbosenames.py | 39 +++-- .../migrations/0017_auto_20160811_1954.py | 15 +- .../0018_hybridflow_and_clientattrs.py | 26 +++- .../0020_client__post_logout_redirect_uris.py | 6 +- .../migrations/0022_auto_20170331_1626.py | 11 +- oidc_provider/models.py | 103 ++++++++----- oidc_provider/settings.py | 1 + .../tests/test_authorize_endpoint.py | 25 +-- .../tests/test_end_session_endpoint.py | 5 +- oidc_provider/tests/test_middleware.py | 1 + oidc_provider/tests/test_token_endpoint.py | 39 ++--- oidc_provider/tests/test_userinfo_endpoint.py | 20 +-- oidc_provider/views.py | 13 +- runtests.py | 26 ++-- tox.ini | 7 + 33 files changed, 365 insertions(+), 231 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index e3760f6..56ebcdb 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,18 +12,18 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import sys -import os +# import sys +# import os # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) +# sys.path.insert(0, os.path.abspath('.')) # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom @@ -38,7 +38,7 @@ templates_path = ['_templates'] # source_suffix = ['.rst'] # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' @@ -66,9 +66,9 @@ language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. @@ -76,27 +76,27 @@ exclude_patterns = ['_build'] # The reST default role (used for this markup: `text`) to use for all # documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False +# keep_warnings = False # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False @@ -111,26 +111,26 @@ html_theme = 'alabaster' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, @@ -140,62 +140,62 @@ html_static_path = ['_static'] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. -#html_extra_path = [] +# html_extra_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Language to be used for generating the HTML full-text search index. # Sphinx supports the following languages: # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' -#html_search_language = 'en' +# html_search_language = 'en' # A dictionary with options for the search language support, empty by default. # Now only 'ja' uses this config value -#html_search_options = {'type': 'default'} +# html_search_options = {'type': 'default'} # The name of a javascript file (relative to the configuration directory) that # implements a search results scorer. If empty, the default will be used. -#html_search_scorer = 'scorer.js' +# html_search_scorer = 'scorer.js' # Output file base name for HTML help builder. htmlhelp_basename = 'django-oidc-providerdoc' @@ -203,17 +203,17 @@ htmlhelp_basename = 'django-oidc-providerdoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', + # The paper size ('letterpaper' or 'a4paper'). + # 'papersize': 'letterpaper', -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', + # The font size ('10pt', '11pt' or '12pt'). + # 'pointsize': '10pt', -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # Additional stuff for the LaTeX preamble. + # 'preamble': '', -# Latex figure (float) alignment -#'figure_align': 'htbp', + # Latex figure (float) alignment + # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples @@ -226,23 +226,23 @@ latex_documents = [ # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output --------------------------------------- @@ -255,7 +255,7 @@ man_pages = [ ] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ------------------------------------------- @@ -270,16 +270,16 @@ texinfo_documents = [ ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False +# texinfo_no_detailmenu = False # -- Options for Epub output ---------------------------------------------- @@ -291,62 +291,62 @@ epub_publisher = author epub_copyright = copyright # The basename for the epub file. It defaults to the project name. -#epub_basename = project +# epub_basename = project # The HTML theme for the epub output. Since the default themes are not # optimized for small screen space, using the same theme for HTML and epub # output is usually not wise. This defaults to 'epub', a theme designed to save # visual space. -#epub_theme = 'epub' +# epub_theme = 'epub' # The language of the text. It defaults to the language option # or 'en' if the language is not set. -#epub_language = '' +# epub_language = '' # The scheme of the identifier. Typical schemes are ISBN or URL. -#epub_scheme = '' +# epub_scheme = '' # The unique identifier of the text. This can be a ISBN number # or the project homepage. -#epub_identifier = '' +# epub_identifier = '' # A unique identification for the text. -#epub_uid = '' +# epub_uid = '' # A tuple containing the cover image and cover page html template filenames. -#epub_cover = () +# epub_cover = () # A sequence of (type, uri, title) tuples for the guide element of content.opf. -#epub_guide = () +# epub_guide = () # HTML files that should be inserted before the pages created by sphinx. # The format is a list of tuples containing the path and title. -#epub_pre_files = [] +# epub_pre_files = [] # HTML files that should be inserted after the pages created by sphinx. # The format is a list of tuples containing the path and title. -#epub_post_files = [] +# epub_post_files = [] # A list of files that should not be packed into the epub file. epub_exclude_files = ['search.html'] # The depth of the table of contents in toc.ncx. -#epub_tocdepth = 3 +# epub_tocdepth = 3 # Allow duplicate toc entries. -#epub_tocdup = True +# epub_tocdup = True # Choose between 'default' and 'includehidden'. -#epub_tocscope = 'default' +# epub_tocscope = 'default' # Fix unsupported image types using the Pillow. -#epub_fix_images = False +# epub_fix_images = False # Scale large images. -#epub_max_image_width = 0 +# epub_max_image_width = 0 # How to display URL addresses: 'footnote', 'no', or 'inline'. -#epub_show_urls = 'inline' +# epub_show_urls = 'inline' # If false, no index is generated. -#epub_use_index = True +# epub_use_index = True diff --git a/example_project/myapp/urls.py b/example_project/myapp/urls.py index 91d31fa..aac3450 100644 --- a/example_project/myapp/urls.py +++ b/example_project/myapp/urls.py @@ -6,8 +6,8 @@ from django.views.generic import TemplateView urlpatterns = [ url(r'^$', TemplateView.as_view(template_name='home.html'), name='home'), - url(r'^accounts/login/$', auth_views.login, { 'template_name': 'login.html' }, name='login'), - url(r'^accounts/logout/$', auth_views.logout, { 'next_page': '/' }, name='logout'), + url(r'^accounts/login/$', auth_views.login, {'template_name': 'login.html'}, name='login'), + url(r'^accounts/logout/$', auth_views.logout, {'next_page': '/'}, name='logout'), url(r'^', include('oidc_provider.urls', namespace='oidc_provider')), diff --git a/example_project/myapp/wsgi.py b/example_project/myapp/wsgi.py index 91caa07..dd74e93 100644 --- a/example_project/myapp/wsgi.py +++ b/example_project/myapp/wsgi.py @@ -1,5 +1,6 @@ import os +from django.core.wsgi import get_wsgi_application + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myapp.settings') -from django.core.wsgi import get_wsgi_application application = get_wsgi_application() diff --git a/oidc_provider/admin.py b/oidc_provider/admin.py index 5ff343b..9197849 100644 --- a/oidc_provider/admin.py +++ b/oidc_provider/admin.py @@ -51,7 +51,9 @@ class ClientAdmin(admin.ModelAdmin): fieldsets = [ [_(u''), { - 'fields': ('name', 'client_type', 'response_type','_redirect_uris', 'jwt_alg', 'require_consent', 'reuse_consent'), + 'fields': ( + 'name', 'client_type', 'response_type', '_redirect_uris', 'jwt_alg', 'require_consent', + 'reuse_consent'), }], [_(u'Credentials'), { 'fields': ('client_id', 'client_secret'), diff --git a/oidc_provider/lib/claims.py b/oidc_provider/lib/claims.py index fe2e716..d4af2ad 100644 --- a/oidc_provider/lib/claims.py +++ b/oidc_provider/lib/claims.py @@ -9,8 +9,8 @@ STANDARD_CLAIMS = { 'name': '', 'given_name': '', 'family_name': '', 'middle_name': '', 'nickname': '', 'preferred_username': '', 'profile': '', 'picture': '', 'website': '', 'gender': '', 'birthdate': '', 'zoneinfo': '', 'locale': '', 'updated_at': '', 'email': '', 'email_verified': '', - 'phone_number': '', 'phone_number_verified': '', 'address': { 'formatted': '', - 'street_address': '', 'locality': '', 'region': '', 'postal_code': '', 'country': '', }, + 'phone_number': '', 'phone_number_verified': '', 'address': { + 'formatted': '', 'street_address': '', 'locality': '', 'region': '', 'postal_code': '', 'country': '', }, } @@ -72,7 +72,9 @@ class ScopeClaims(object): return aux_dic @classmethod - def get_scopes_info(cls, scopes=[]): + def get_scopes_info(cls, scopes=None): + if scopes is None: + scopes = [] scopes_info = [] for name in cls.__dict__: @@ -99,6 +101,7 @@ class StandardScopeClaims(ScopeClaims): _(u'Basic profile'), _(u'Access to your basic information. Includes names, gender, birthdate and other information.'), ) + def scope_profile(self): dic = { 'name': self.userinfo.get('name'), @@ -123,6 +126,7 @@ class StandardScopeClaims(ScopeClaims): _(u'Email'), _(u'Access to your email address.'), ) + def scope_email(self): dic = { 'email': self.userinfo.get('email') or getattr(self.user, 'email', None), @@ -135,6 +139,7 @@ class StandardScopeClaims(ScopeClaims): _(u'Phone number'), _(u'Access to your phone number.'), ) + def scope_phone(self): dic = { 'phone_number': self.userinfo.get('phone_number'), @@ -147,6 +152,7 @@ class StandardScopeClaims(ScopeClaims): _(u'Address information'), _(u'Access to your address. Includes country, locality, street and other information.'), ) + def scope_address(self): dic = { 'address': { diff --git a/oidc_provider/lib/endpoints/authorize.py b/oidc_provider/lib/endpoints/authorize.py index 463a35c..3313bd6 100644 --- a/oidc_provider/lib/endpoints/authorize.py +++ b/oidc_provider/lib/endpoints/authorize.py @@ -102,8 +102,8 @@ class AuthorizeEndpoint(object): logger.debug('[Authorize] Invalid response type: %s', self.params['response_type']) raise AuthorizeError(self.params['redirect_uri'], 'unsupported_response_type', self.grant_type) - if not self.is_authentication and \ - (self.grant_type == 'hybrid' or self.params['response_type'] in ['id_token', 'id_token token']): + if (not self.is_authentication and + (self.grant_type == 'hybrid' or self.params['response_type'] in ['id_token', 'id_token token'])): logger.debug('[Authorize] Missing openid scope.') raise AuthorizeError(self.params['redirect_uri'], 'invalid_scope', self.grant_type) @@ -165,7 +165,8 @@ class AuthorizeEndpoint(object): id_token_dic = create_id_token(**kwargs) # Check if response_type must include id_token in the response. - if self.params['response_type'] in ['id_token', 'id_token token', 'code id_token', 'code id_token token']: + if self.params['response_type'] in [ + 'id_token', 'id_token token', 'code id_token', 'code id_token token']: query_fragment['id_token'] = encode_id_token(id_token_dic, self.client) else: id_token_dic = {} @@ -211,7 +212,8 @@ class AuthorizeEndpoint(object): logger.exception('[Authorize] Error when trying to create response uri: %s', error) raise AuthorizeError(self.params['redirect_uri'], 'server_error', self.grant_type) - uri = uri._replace(query=urlencode(query_params, doseq=True), fragment=uri.fragment + urlencode(query_fragment, doseq=True)) + uri = uri._replace( + query=urlencode(query_params, doseq=True), fragment=uri.fragment + urlencode(query_fragment, doseq=True)) return urlunsplit(uri) @@ -264,7 +266,8 @@ class AuthorizeEndpoint(object): """ scopes = StandardScopeClaims.get_scopes_info(self.params['scope']) if settings.get('OIDC_EXTRA_SCOPE_CLAIMS'): - scopes_extra = settings.get('OIDC_EXTRA_SCOPE_CLAIMS', import_str=True).get_scopes_info(self.params['scope']) + scopes_extra = settings.get('OIDC_EXTRA_SCOPE_CLAIMS', import_str=True).get_scopes_info( + self.params['scope']) for index_extra, scope_extra in enumerate(scopes_extra): for index, scope in enumerate(scopes[:]): if scope_extra['scope'] == scope['scope']: diff --git a/oidc_provider/lib/endpoints/token.py b/oidc_provider/lib/endpoints/token.py index abba70a..5a6b0af 100644 --- a/oidc_provider/lib/endpoints/token.py +++ b/oidc_provider/lib/endpoints/token.py @@ -4,11 +4,6 @@ import logging import re from django.contrib.auth import authenticate -try: - from urllib.parse import unquote -except ImportError: - from urllib import unquote - from django.http import JsonResponse from oidc_provider.lib.errors import ( diff --git a/oidc_provider/lib/errors.py b/oidc_provider/lib/errors.py index 47f4b10..8533a75 100644 --- a/oidc_provider/lib/errors.py +++ b/oidc_provider/lib/errors.py @@ -31,6 +31,7 @@ class UserAuthError(Exception): 'error_description': self.description, } + class AuthorizeError(Exception): _errors = { diff --git a/oidc_provider/lib/utils/common.py b/oidc_provider/lib/utils/common.py index 09bdf00..c69a1b8 100644 --- a/oidc_provider/lib/utils/common.py +++ b/oidc_provider/lib/utils/common.py @@ -5,11 +5,6 @@ from django.http import HttpResponse from oidc_provider import settings -try: - from urlparse import urlsplit, urlunsplit -except ImportError: - from urllib.parse import urlsplit, urlunsplit - def redirect(uri): """ @@ -75,7 +70,8 @@ def default_after_userlogin_hook(request, user, client): return None -def default_after_end_session_hook(request, id_token=None, post_logout_redirect_uri=None, state=None, client=None, next_page=None): +def default_after_end_session_hook( + request, id_token=None, post_logout_redirect_uri=None, state=None, client=None, next_page=None): """ Default function for setting OIDC_AFTER_END_SESSION_HOOK. @@ -91,7 +87,8 @@ def default_after_end_session_hook(request, id_token=None, post_logout_redirect_ :param state: state param from url query params :type state: str - :param client: If id_token has `aud` param and associated Client exists, this is an instance of it - do NOT trust this param + :param client: If id_token has `aud` param and associated Client exists, + this is an instance of it - do NOT trust this param :type client: oidc_provider.models.Client :param next_page: calculated next_page redirection target diff --git a/oidc_provider/lib/utils/oauth2.py b/oidc_provider/lib/utils/oauth2.py index eba482c..bfb7849 100644 --- a/oidc_provider/lib/utils/oauth2.py +++ b/oidc_provider/lib/utils/oauth2.py @@ -28,12 +28,15 @@ def extract_access_token(request): return access_token -def protected_resource_view(scopes=[]): +def protected_resource_view(scopes=None): """ View decorator. The client accesses protected resources by presenting the access token to the resource server. https://tools.ietf.org/html/rfc6749#section-7 """ + if scopes is None: + scopes = [] + def wrapper(view): def view_wrapper(request, *args, **kwargs): access_token = extract_access_token(request) @@ -52,9 +55,10 @@ def protected_resource_view(scopes=[]): if not set(scopes).issubset(set(kwargs['token'].scope)): logger.debug('[UserInfo] Missing openid scope.') raise BearerTokenError('insufficient_scope') - except (BearerTokenError) as error: + except BearerTokenError as error: response = HttpResponse(status=error.status) - response['WWW-Authenticate'] = 'error="{0}", error_description="{1}"'.format(error.code, error.description) + response['WWW-Authenticate'] = 'error="{0}", error_description="{1}"'.format( + error.code, error.description) return response return view(request, *args, **kwargs) diff --git a/oidc_provider/lib/utils/token.py b/oidc_provider/lib/utils/token.py index 3f0d2e9..73fd62e 100644 --- a/oidc_provider/lib/utils/token.py +++ b/oidc_provider/lib/utils/token.py @@ -18,12 +18,14 @@ from oidc_provider.models import ( from oidc_provider import settings -def create_id_token(user, aud, nonce='', at_hash='', request=None, scope=[]): +def create_id_token(user, aud, nonce='', at_hash='', request=None, scope=None): """ Creates the id_token dictionary. See: http://openid.net/specs/openid-connect-core-1_0.html#IDToken Return a dic. """ + if scope is None: + scope = [] sub = settings.get('OIDC_IDTOKEN_SUB_GENERATOR', import_str=True)(user=user) expires_in = settings.get('OIDC_IDTOKEN_EXPIRE') @@ -63,6 +65,7 @@ def create_id_token(user, aud, nonce='', at_hash='', request=None, scope=[]): return dic + def encode_id_token(payload, client): """ Represent the ID Token as a JSON Web Token (JWT). @@ -72,6 +75,7 @@ def encode_id_token(payload, client): _jws = JWS(payload, alg=client.jwt_alg) return _jws.sign_compact(keys) + def decode_id_token(token, client): """ Represent the ID Token as a JSON Web Token (JWT). @@ -80,6 +84,7 @@ def decode_id_token(token, client): keys = get_client_alg_keys(client) return JWS().verify_compact(token, keys=keys) + def client_id_from_id_token(id_token): """ Extracts the client id from a JSON Web Token (JWT). @@ -88,6 +93,7 @@ def client_id_from_id_token(id_token): payload = JWT().unpack(id_token).payload() return payload.get('aud', None) + def create_token(user, client, scope, id_token_dic=None): """ Create and populate a Token object. @@ -108,6 +114,7 @@ def create_token(user, client, scope, id_token_dic=None): return token + def create_code(user, client, scope, nonce, is_authentication, code_challenge=None, code_challenge_method=None): """ @@ -132,6 +139,7 @@ def create_code(user, client, scope, nonce, is_authentication, return code + def get_client_alg_keys(client): """ Takes a client and returns the set of keys associated with it. diff --git a/oidc_provider/management/commands/creatersakey.py b/oidc_provider/management/commands/creatersakey.py index 1dc1a2c..d5d423f 100644 --- a/oidc_provider/management/commands/creatersakey.py +++ b/oidc_provider/management/commands/creatersakey.py @@ -1,9 +1,6 @@ -import os - from Cryptodome.PublicKey import RSA from django.core.management.base import BaseCommand -from oidc_provider import settings from oidc_provider.models import RSAKey diff --git a/oidc_provider/migrations/0001_initial.py b/oidc_provider/migrations/0001_initial.py index ca32b7e..0a24114 100644 --- a/oidc_provider/migrations/0001_initial.py +++ b/oidc_provider/migrations/0001_initial.py @@ -20,7 +20,9 @@ class Migration(migrations.Migration): ('name', models.CharField(default=b'', max_length=100)), ('client_id', models.CharField(unique=True, max_length=255)), ('client_secret', models.CharField(unique=True, max_length=255)), - ('response_type', models.CharField(max_length=30, choices=[(b'code', b'code (Authorization Code Flow)'), (b'id_token', b'id_token (Implicit Flow)'), (b'id_token token', b'id_token token (Implicit Flow)')])), + ('response_type', models.CharField(max_length=30, choices=[ + (b'code', b'code (Authorization Code Flow)'), (b'id_token', b'id_token (Implicit Flow)'), + (b'id_token token', b'id_token token (Implicit Flow)')])), ('_redirect_uris', models.TextField(default=b'')), ], options={ diff --git a/oidc_provider/migrations/0004_remove_userinfo.py b/oidc_provider/migrations/0004_remove_userinfo.py index 33df109..d4208e0 100644 --- a/oidc_provider/migrations/0004_remove_userinfo.py +++ b/oidc_provider/migrations/0004_remove_userinfo.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -from django.db import models, migrations +from django.db import migrations class Migration(migrations.Migration): diff --git a/oidc_provider/migrations/0007_auto_20160111_1844.py b/oidc_provider/migrations/0007_auto_20160111_1844.py index a160cc5..263c4c5 100644 --- a/oidc_provider/migrations/0007_auto_20160111_1844.py +++ b/oidc_provider/migrations/0007_auto_20160111_1844.py @@ -29,7 +29,8 @@ class Migration(migrations.Migration): migrations.AddField( model_name='client', name='date_created', - field=models.DateField(auto_now_add=True, default=datetime.datetime(2016, 1, 11, 18, 44, 32, 192477, tzinfo=utc)), + field=models.DateField( + auto_now_add=True, default=datetime.datetime(2016, 1, 11, 18, 44, 32, 192477, tzinfo=utc)), preserve_default=False, ), migrations.AlterField( diff --git a/oidc_provider/migrations/0011_client_client_type.py b/oidc_provider/migrations/0011_client_client_type.py index 26e9fc3..563096f 100644 --- a/oidc_provider/migrations/0011_client_client_type.py +++ b/oidc_provider/migrations/0011_client_client_type.py @@ -15,6 +15,11 @@ class Migration(migrations.Migration): migrations.AddField( model_name='client', name='client_type', - field=models.CharField(choices=[(b'confidential', b'Confidential'), (b'public', b'Public')], default=b'confidential', help_text='Confidential clients are capable of maintaining the confidentiality of their credentials. Public clients are incapable.', max_length=30), + field=models.CharField( + choices=[(b'confidential', b'Confidential'), (b'public', b'Public')], + default=b'confidential', + help_text='Confidential clients are capable of maintaining the confidentiality of their ' + 'credentials. Public clients are incapable.', + max_length=30), ), ] diff --git a/oidc_provider/migrations/0014_client_jwt_alg.py b/oidc_provider/migrations/0014_client_jwt_alg.py index d2b096c..18a34c2 100644 --- a/oidc_provider/migrations/0014_client_jwt_alg.py +++ b/oidc_provider/migrations/0014_client_jwt_alg.py @@ -15,6 +15,10 @@ class Migration(migrations.Migration): migrations.AddField( model_name='client', name='jwt_alg', - field=models.CharField(choices=[(b'HS256', b'HS256'), (b'RS256', b'RS256')], default=b'RS256', max_length=10, verbose_name='JWT Algorithm'), + field=models.CharField( + choices=[(b'HS256', b'HS256'), (b'RS256', b'RS256')], + default=b'RS256', + max_length=10, + verbose_name='JWT Algorithm'), ), ] diff --git a/oidc_provider/migrations/0015_change_client_code.py b/oidc_provider/migrations/0015_change_client_code.py index bfffd57..a4f67e1 100644 --- a/oidc_provider/migrations/0015_change_client_code.py +++ b/oidc_provider/migrations/0015_change_client_code.py @@ -25,12 +25,21 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='client', name='client_type', - field=models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], default='confidential', help_text='Confidential clients are capable of maintaining the confidentiality of their credentials. Public clients are incapable.', max_length=30), + field=models.CharField( + choices=[('confidential', 'Confidential'), ('public', 'Public')], + default='confidential', + help_text='Confidential clients are capable of maintaining the confidentiality of their' + ' credentials. Public clients are incapable.', + max_length=30), ), migrations.AlterField( model_name='client', name='jwt_alg', - field=models.CharField(choices=[('HS256', 'HS256'), ('RS256', 'RS256')], default='RS256', max_length=10, verbose_name='JWT Algorithm'), + field=models.CharField( + choices=[('HS256', 'HS256'), ('RS256', 'RS256')], + default='RS256', + max_length=10, + verbose_name='JWT Algorithm'), ), migrations.AlterField( model_name='client', @@ -40,7 +49,11 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='client', name='response_type', - field=models.CharField(choices=[('code', 'code (Authorization Code Flow)'), ('id_token', 'id_token (Implicit Flow)'), ('id_token token', 'id_token token (Implicit Flow)')], max_length=30), + field=models.CharField( + choices=[ + ('code', 'code (Authorization Code Flow)'), ('id_token', 'id_token (Implicit Flow)'), + ('id_token token', 'id_token token (Implicit Flow)')], + max_length=30), ), migrations.AlterField( model_name='code', diff --git a/oidc_provider/migrations/0016_userconsent_and_verbosenames.py b/oidc_provider/migrations/0016_userconsent_and_verbosenames.py index afd043e..a698362 100644 --- a/oidc_provider/migrations/0016_userconsent_and_verbosenames.py +++ b/oidc_provider/migrations/0016_userconsent_and_verbosenames.py @@ -19,13 +19,15 @@ class Migration(migrations.Migration): migrations.AddField( model_name='userconsent', name='date_given', - field=models.DateTimeField(default=datetime.datetime(2016, 6, 10, 17, 53, 48, 889808, tzinfo=utc), verbose_name='Date Given'), + field=models.DateTimeField( + default=datetime.datetime(2016, 6, 10, 17, 53, 48, 889808, tzinfo=utc), verbose_name='Date Given'), preserve_default=False, ), migrations.AlterField( model_name='client', name='_redirect_uris', - field=models.TextField(default=b'', help_text='Enter each URI on a new line.', verbose_name='Redirect URIs'), + field=models.TextField( + default=b'', help_text='Enter each URI on a new line.', verbose_name='Redirect URIs'), ), migrations.AlterField( model_name='client', @@ -40,7 +42,13 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='client', name='client_type', - field=models.CharField(choices=[(b'confidential', b'Confidential'), (b'public', b'Public')], default=b'confidential', help_text='Confidential clients are capable of maintaining the confidentiality of their credentials. Public clients are incapable.', max_length=30, verbose_name='Client Type'), + field=models.CharField( + choices=[(b'confidential', b'Confidential'), (b'public', b'Public')], + default=b'confidential', + help_text='Confidential clients are capable of maintaining the confidentiality of their ' + 'credentials. Public clients are incapable.', + max_length=30, + verbose_name='Client Type'), ), migrations.AlterField( model_name='client', @@ -55,7 +63,12 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='client', name='response_type', - field=models.CharField(choices=[(b'code', b'code (Authorization Code Flow)'), (b'id_token', b'id_token (Implicit Flow)'), (b'id_token token', b'id_token token (Implicit Flow)')], max_length=30, verbose_name='Response Type'), + field=models.CharField( + choices=[ + (b'code', b'code (Authorization Code Flow)'), (b'id_token', b'id_token (Implicit Flow)'), + (b'id_token token', b'id_token token (Implicit Flow)')], + max_length=30, + verbose_name='Response Type'), ), migrations.AlterField( model_name='code', @@ -65,7 +78,8 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='code', name='client', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.Client', verbose_name='Client'), + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.Client', verbose_name='Client'), ), migrations.AlterField( model_name='code', @@ -100,7 +114,8 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='code', name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'), + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'), ), migrations.AlterField( model_name='rsakey', @@ -125,7 +140,8 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='token', name='client', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.Client', verbose_name='Client'), + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.Client', verbose_name='Client'), ), migrations.AlterField( model_name='token', @@ -140,7 +156,8 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='token', name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'), + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'), ), migrations.AlterField( model_name='userconsent', @@ -150,7 +167,8 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='userconsent', name='client', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.Client', verbose_name='Client'), + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.Client', verbose_name='Client'), ), migrations.AlterField( model_name='userconsent', @@ -160,6 +178,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='userconsent', name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'), + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'), ), ] diff --git a/oidc_provider/migrations/0017_auto_20160811_1954.py b/oidc_provider/migrations/0017_auto_20160811_1954.py index de7350f..2d564e3 100644 --- a/oidc_provider/migrations/0017_auto_20160811_1954.py +++ b/oidc_provider/migrations/0017_auto_20160811_1954.py @@ -25,7 +25,13 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='client', name='client_type', - field=models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], default='confidential', help_text='Confidential clients are capable of maintaining the confidentiality of their credentials. Public clients are incapable.', max_length=30, verbose_name='Client Type'), + field=models.CharField( + choices=[('confidential', 'Confidential'), ('public', 'Public')], + default='confidential', + help_text='Confidential clients are capable of maintaining the confidentiality of their ' + 'credentials. Public clients are incapable.', + max_length=30, + verbose_name='Client Type'), ), migrations.AlterField( model_name='client', @@ -35,7 +41,12 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='client', name='response_type', - field=models.CharField(choices=[('code', 'code (Authorization Code Flow)'), ('id_token', 'id_token (Implicit Flow)'), ('id_token token', 'id_token token (Implicit Flow)')], max_length=30, verbose_name='Response Type'), + field=models.CharField( + choices=[ + ('code', 'code (Authorization Code Flow)'), ('id_token', 'id_token (Implicit Flow)'), + ('id_token token', 'id_token token (Implicit Flow)')], + max_length=30, + verbose_name='Response Type'), ), migrations.AlterField( model_name='code', diff --git a/oidc_provider/migrations/0018_hybridflow_and_clientattrs.py b/oidc_provider/migrations/0018_hybridflow_and_clientattrs.py index c915cf8..06328dd 100644 --- a/oidc_provider/migrations/0018_hybridflow_and_clientattrs.py +++ b/oidc_provider/migrations/0018_hybridflow_and_clientattrs.py @@ -20,12 +20,18 @@ class Migration(migrations.Migration): migrations.AddField( model_name='client', name='logo', - field=models.FileField(blank=True, default='', upload_to='oidc_provider/clients', verbose_name='Logo Image'), + field=models.FileField( + blank=True, default='', upload_to='oidc_provider/clients', verbose_name='Logo Image'), ), migrations.AddField( model_name='client', name='terms_url', - field=models.CharField(blank=True, default='', help_text='External reference to the privacy policy of the client.', max_length=255, verbose_name='Terms URL'), + field=models.CharField( + blank=True, + default='', + help_text='External reference to the privacy policy of the client.', + max_length=255, + verbose_name='Terms URL'), ), migrations.AddField( model_name='client', @@ -35,11 +41,23 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='client', name='jwt_alg', - field=models.CharField(choices=[('HS256', 'HS256'), ('RS256', 'RS256')], default='RS256', help_text='Algorithm used to encode ID Tokens.', max_length=10, verbose_name='JWT Algorithm'), + field=models.CharField( + choices=[('HS256', 'HS256'), ('RS256', 'RS256')], + default='RS256', + help_text='Algorithm used to encode ID Tokens.', + max_length=10, + verbose_name='JWT Algorithm'), ), migrations.AlterField( model_name='client', name='response_type', - field=models.CharField(choices=[('code', 'code (Authorization Code Flow)'), ('id_token', 'id_token (Implicit Flow)'), ('id_token token', 'id_token token (Implicit Flow)'), ('code token', 'code token (Hybrid Flow)'), ('code id_token', 'code id_token (Hybrid Flow)'), ('code id_token token', 'code id_token token (Hybrid Flow)')], max_length=30, verbose_name='Response Type'), + field=models.CharField( + choices=[ + ('code', 'code (Authorization Code Flow)'), ('id_token', 'id_token (Implicit Flow)'), + ('id_token token', 'id_token token (Implicit Flow)'), ('code token', 'code token (Hybrid Flow)'), + ('code id_token', 'code id_token (Hybrid Flow)'), + ('code id_token token', 'code id_token token (Hybrid Flow)')], + max_length=30, + verbose_name='Response Type'), ), ] diff --git a/oidc_provider/migrations/0020_client__post_logout_redirect_uris.py b/oidc_provider/migrations/0020_client__post_logout_redirect_uris.py index db8e6d6..158da24 100644 --- a/oidc_provider/migrations/0020_client__post_logout_redirect_uris.py +++ b/oidc_provider/migrations/0020_client__post_logout_redirect_uris.py @@ -15,6 +15,10 @@ class Migration(migrations.Migration): migrations.AddField( model_name='client', name='_post_logout_redirect_uris', - field=models.TextField(blank=True, default='', help_text='Enter each URI on a new line.', verbose_name='Post Logout Redirect URIs'), + field=models.TextField( + blank=True, + default='', + help_text='Enter each URI on a new line.', + verbose_name='Post Logout Redirect URIs'), ), ] diff --git a/oidc_provider/migrations/0022_auto_20170331_1626.py b/oidc_provider/migrations/0022_auto_20170331_1626.py index bad8c93..78b7026 100644 --- a/oidc_provider/migrations/0022_auto_20170331_1626.py +++ b/oidc_provider/migrations/0022_auto_20170331_1626.py @@ -15,11 +15,18 @@ class Migration(migrations.Migration): migrations.AddField( model_name='client', name='require_consent', - field=models.BooleanField(default=True, help_text='If disabled, the Server will NEVER ask the user for consent.', verbose_name='Require Consent?'), + field=models.BooleanField( + default=True, + help_text='If disabled, the Server will NEVER ask the user for consent.', + verbose_name='Require Consent?'), ), migrations.AddField( model_name='client', name='reuse_consent', - field=models.BooleanField(default=True, help_text="If enabled, the Server will save the user consent given to a specific client, so that user won't be prompted for the same authorization multiple times.", verbose_name='Reuse Consent?'), + field=models.BooleanField( + default=True, + help_text="If enabled, the Server will save the user consent given to a specific client," + " so that user won't be prompted for the same authorization multiple times.", + verbose_name='Reuse Consent?'), ), ] diff --git a/oidc_provider/models.py b/oidc_provider/models.py index a196239..4e28dde 100644 --- a/oidc_provider/models.py +++ b/oidc_provider/models.py @@ -33,36 +33,66 @@ JWT_ALGS = [ class Client(models.Model): name = models.CharField(max_length=100, default='', verbose_name=_(u'Name')) - client_type = models.CharField(max_length=30, choices=CLIENT_TYPE_CHOICES, default='confidential', verbose_name=_(u'Client Type'), help_text=_(u'Confidential clients are capable of maintaining the confidentiality of their credentials. Public clients are incapable.')) + client_type = models.CharField( + max_length=30, + choices=CLIENT_TYPE_CHOICES, + default='confidential', + verbose_name=_(u'Client Type'), + help_text=_(u'Confidential clients are capable of maintaining the confidentiality of their credentials. ' + u'Public clients are incapable.')) client_id = models.CharField(max_length=255, unique=True, verbose_name=_(u'Client ID')) client_secret = models.CharField(max_length=255, blank=True, verbose_name=_(u'Client SECRET')) response_type = models.CharField(max_length=30, choices=RESPONSE_TYPE_CHOICES, verbose_name=_(u'Response Type')) - jwt_alg = models.CharField(max_length=10, choices=JWT_ALGS, default='RS256', verbose_name=_(u'JWT Algorithm'), help_text=_(u'Algorithm used to encode ID Tokens.')) + jwt_alg = models.CharField( + max_length=10, + choices=JWT_ALGS, + default='RS256', + verbose_name=_(u'JWT Algorithm'), + help_text=_(u'Algorithm used to encode ID Tokens.')) date_created = models.DateField(auto_now_add=True, verbose_name=_(u'Date Created')) website_url = models.CharField(max_length=255, blank=True, default='', verbose_name=_(u'Website URL')) - terms_url = models.CharField(max_length=255, blank=True, default='', verbose_name=_(u'Terms URL'), help_text=_(u'External reference to the privacy policy of the client.')) + terms_url = models.CharField( + max_length=255, + blank=True, + default='', + verbose_name=_(u'Terms URL'), + help_text=_(u'External reference to the privacy policy of the client.')) contact_email = models.CharField(max_length=255, blank=True, default='', verbose_name=_(u'Contact Email')) logo = models.FileField(blank=True, default='', upload_to='oidc_provider/clients', verbose_name=_(u'Logo Image')) - reuse_consent = models.BooleanField(default=True, verbose_name=_('Reuse Consent?'), help_text=_('If enabled, the Server will save the user consent given to a specific client, so that user won\'t be prompted for the same authorization multiple times.')) - require_consent = models.BooleanField(default=True, verbose_name=_('Require Consent?'), help_text=_('If disabled, the Server will NEVER ask the user for consent.')) + reuse_consent = models.BooleanField( + default=True, + verbose_name=_('Reuse Consent?'), + help_text=_('If enabled, the Server will save the user consent given to a specific client, so that' + ' user won\'t be prompted for the same authorization multiple times.')) + require_consent = models.BooleanField( + default=True, + verbose_name=_('Require Consent?'), + help_text=_('If disabled, the Server will NEVER ask the user for consent.')) - _redirect_uris = models.TextField(default='', verbose_name=_(u'Redirect URIs'), help_text=_(u'Enter each URI on a new line.')) - def redirect_uris(): - def fget(self): - return self._redirect_uris.splitlines() - def fset(self, value): - self._redirect_uris = '\n'.join(value) - return locals() - redirect_uris = property(**redirect_uris()) + _redirect_uris = models.TextField( + default='', verbose_name=_(u'Redirect URIs'), help_text=_(u'Enter each URI on a new line.')) - _post_logout_redirect_uris = models.TextField(blank=True, default='', verbose_name=_(u'Post Logout Redirect URIs'), help_text=_(u'Enter each URI on a new line.')) - def post_logout_redirect_uris(): - def fget(self): - return self._post_logout_redirect_uris.splitlines() - def fset(self, value): - self._post_logout_redirect_uris = '\n'.join(value) - return locals() - post_logout_redirect_uris = property(**post_logout_redirect_uris()) + @property + def redirect_uris(self): + return self._redirect_uris.splitlines() + + @redirect_uris.setter + def redirect_uris(self, value): + self._redirect_uris = '\n'.join(value) + + _post_logout_redirect_uris = models.TextField( + blank=True, + default='', + verbose_name=_(u'Post Logout Redirect URIs'), + help_text=_(u'Enter each URI on a new line.')) + + @property + def post_logout_redirect_uris(self): + return self._post_logout_redirect_uris.splitlines() + + @post_logout_redirect_uris.setter + def post_logout_redirect_uris(self, value): + self._post_logout_redirect_uris = '\n'.join(value) class Meta: verbose_name = _(u'Client') @@ -74,8 +104,6 @@ class Client(models.Model): def __unicode__(self): return self.__str__() - - @property def default_redirect_uri(self): return self.redirect_uris[0] if self.redirect_uris else '' @@ -88,15 +116,13 @@ class BaseCodeTokenModel(models.Model): expires_at = models.DateTimeField(verbose_name=_(u'Expiration Date')) _scope = models.TextField(default='', verbose_name=_(u'Scopes')) - def scope(): - def fget(self): - return self._scope.split() + @property + def scope(self): + return self._scope.split() - def fset(self, value): - self._scope = ' '.join(value) - - return locals() - scope = property(**scope()) + @scope.setter + def scope(self, value): + self._scope = ' '.join(value) def has_expired(self): return timezone.now() >= self.expires_at @@ -130,16 +156,13 @@ class Token(BaseCodeTokenModel): refresh_token = models.CharField(max_length=255, unique=True, verbose_name=_(u'Refresh Token')) _id_token = models.TextField(verbose_name=_(u'ID Token')) - def id_token(): + @property + def id_token(self): + return json.loads(self._id_token) - def fget(self): - return json.loads(self._id_token) - - def fset(self, value): - self._id_token = json.dumps(value) - - return locals() - id_token = property(**id_token()) + @id_token.setter + def id_token(self, value): + self._id_token = json.dumps(value) class Meta: verbose_name = _(u'Token') diff --git a/oidc_provider/settings.py b/oidc_provider/settings.py index fc2b4c9..7c7da44 100644 --- a/oidc_provider/settings.py +++ b/oidc_provider/settings.py @@ -145,6 +145,7 @@ class DefaultSettings(object): 'error': 'oidc_provider/error.html' } + default_settings = DefaultSettings() diff --git a/oidc_provider/tests/test_authorize_endpoint.py b/oidc_provider/tests/test_authorize_endpoint.py index 361f27f..b498425 100644 --- a/oidc_provider/tests/test_authorize_endpoint.py +++ b/oidc_provider/tests/test_authorize_endpoint.py @@ -34,7 +34,9 @@ from oidc_provider.lib.endpoints.authorize import AuthorizeEndpoint class AuthorizeEndpointMixin(object): - def _auth_request(self, method, data={}, is_user_authenticated=False): + def _auth_request(self, method, data=None, is_user_authenticated=False): + if data is None: + data = {} url = reverse('oidc_provider:authorize') if method.lower() == 'get': @@ -67,7 +69,8 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): self.client = create_fake_client(response_type='code') self.client_with_no_consent = create_fake_client(response_type='code', require_consent=False) self.client_public = create_fake_client(response_type='code', is_public=True) - self.client_public_with_no_consent = create_fake_client(response_type='code', is_public=True, require_consent=False) + self.client_public_with_no_consent = create_fake_client( + response_type='code', is_public=True, require_consent=False) self.state = uuid.uuid4().hex self.nonce = uuid.uuid4().hex @@ -163,8 +166,7 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): for key, value in iter(to_check.items()): is_input_ok = input_html.format(key, value) in response.content.decode('utf-8') - self.assertEqual(is_input_ok, True, - msg='Hidden input for "' + key + '" fails.') + self.assertEqual(is_input_ok, True, msg='Hidden input for "' + key + '" fails.') def test_user_consent_response(self): """ @@ -204,8 +206,7 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): is_code_ok = is_code_valid(url=response['Location'], user=self.user, client=self.client) - self.assertEqual(is_code_ok, True, - msg='Code returned is invalid.') + self.assertEqual(is_code_ok, True, msg='Code returned is invalid.') # Check if the state is returned. state = (response['Location'].split('state='))[1].split('&')[0] @@ -276,9 +277,10 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): client=self.client) self.assertTrue(is_code_ok, msg='Code returned is invalid or missing') - self.assertEquals(set(params.keys()), set(['state', 'code']), msg='More than state or code appended as query params') + self.assertEquals(set(params.keys()), {'state', 'code'}, msg='More than state or code appended as query params') - self.assertTrue(response['Location'].startswith(self.client.default_redirect_uri), msg='Different redirect_uri returned') + self.assertTrue( + response['Location'].startswith(self.client.default_redirect_uri), msg='Different redirect_uri returned') def test_unknown_redirect_uris_are_rejected(self): """ @@ -372,7 +374,8 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): self.assertNotIn( quote('prompt=login'), response['Location'], - "Found prompt=login, this leads to infinite login loop. See https://github.com/juanifioren/django-oidc-provider/issues/197." + "Found prompt=login, this leads to infinite login loop. See " + "https://github.com/juanifioren/django-oidc-provider/issues/197." ) response = self._auth_request('get', data, is_user_authenticated=True) @@ -381,7 +384,8 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): self.assertNotIn( quote('prompt=login'), response['Location'], - "Found prompt=login, this leads to infinite login loop. See https://github.com/juanifioren/django-oidc-provider/issues/197." + "Found prompt=login, this leads to infinite login loop. See " + "https://github.com/juanifioren/django-oidc-provider/issues/197." ) def test_prompt_login_none_parameter(self): @@ -447,7 +451,6 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): self.assertIn('consent_required', response['Location']) - class AuthorizationImplicitFlowTestCase(TestCase, AuthorizeEndpointMixin): """ Test cases for Authorization Endpoint using Implicit Flow. diff --git a/oidc_provider/tests/test_end_session_endpoint.py b/oidc_provider/tests/test_end_session_endpoint.py index b416762..651c70a 100644 --- a/oidc_provider/tests/test_end_session_endpoint.py +++ b/oidc_provider/tests/test_end_session_endpoint.py @@ -50,5 +50,6 @@ class EndSessionTestCase(TestCase): def test_call_post_end_session_hook(self, hook_function): self.client.get(self.url) self.assertTrue(hook_function.called, 'OIDC_AFTER_END_SESSION_HOOK should be called') - self.assertTrue(hook_function.call_count == 1, 'OIDC_AFTER_END_SESSION_HOOK should be called once but was {}'.format(hook_function.call_count)) - + self.assertTrue( + hook_function.call_count == 1, + 'OIDC_AFTER_END_SESSION_HOOK should be called once but was {}'.format(hook_function.call_count)) diff --git a/oidc_provider/tests/test_middleware.py b/oidc_provider/tests/test_middleware.py index c2a02df..5a3cc5f 100644 --- a/oidc_provider/tests/test_middleware.py +++ b/oidc_provider/tests/test_middleware.py @@ -10,6 +10,7 @@ class StubbedViews: urlpatterns = [url('^test/', SampleView.as_view())] + MW_CLASSES = ('django.contrib.sessions.middleware.SessionMiddleware', 'oidc_provider.middleware.SessionManagementMiddleware') diff --git a/oidc_provider/tests/test_token_endpoint.py b/oidc_provider/tests/test_token_endpoint.py index b23d4fd..49b7598 100644 --- a/oidc_provider/tests/test_token_endpoint.py +++ b/oidc_provider/tests/test_token_endpoint.py @@ -18,7 +18,7 @@ from django.test import TestCase from jwkest.jwk import KEYS from jwkest.jws import JWS from jwkest.jwt import JWT -from mock import patch, Mock +from mock import patch from oidc_provider.lib.utils.token import create_code from oidc_provider.models import Token @@ -101,7 +101,8 @@ class TokenTestCase(TestCase): """ url = reverse('oidc_provider:token') - request = self.factory.post(url, + request = self.factory.post( + url, data=urlencode(post_data), content_type='application/x-www-form-urlencoded', **extras) @@ -371,7 +372,7 @@ class TokenTestCase(TestCase): response_dic2 = json.loads(response.content.decode('utf-8')) - if scope and set(scope) - set(code.scope): # too broad scope + 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') @@ -427,7 +428,6 @@ class TokenTestCase(TestCase): See http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest and http://openid.net/specs/openid-connect-core-1_0.html#HybridTokenRequest. """ - SIGKEYS = self._get_keys() code = self._create_code() post_data = self._auth_code_post_data(code=code.code) @@ -465,15 +465,13 @@ class TokenTestCase(TestCase): for request in requests: response = TokenView.as_view()(request) - self.assertEqual(response.status_code == 405, True, - msg=request.method + ' request does not return a 405 status.') + self.assertEqual(response.status_code, 405, msg=request.method + ' request does not return a 405 status.') request = self.factory.post(url) response = TokenView.as_view()(request) - self.assertEqual(response.status_code == 400, True, - msg=request.method + ' request does not return a 400 status.') + self.assertEqual(response.status_code, 400, msg=request.method + ' request does not return a 400 status.') def test_client_authentication(self): """ @@ -490,9 +488,10 @@ class TokenTestCase(TestCase): response = self._post_request(post_data) - self.assertEqual('invalid_client' in response.content.decode('utf-8'), - False, - msg='Client authentication fails using request-body credentials.') + self.assertNotIn( + 'invalid_client', + response.content.decode('utf-8'), + msg='Client authentication fails using request-body credentials.') # Now, test with an invalid client_id. invalid_data = post_data.copy() @@ -504,9 +503,10 @@ class TokenTestCase(TestCase): response = self._post_request(invalid_data) - self.assertEqual('invalid_client' in response.content.decode('utf-8'), - True, - msg='Client authentication success with an invalid "client_id".') + self.assertIn( + 'invalid_client', + response.content.decode('utf-8'), + msg='Client authentication success with an invalid "client_id".') # Now, test using HTTP Basic Authentication method. basicauth_data = post_data.copy() @@ -521,9 +521,10 @@ class TokenTestCase(TestCase): response = self._post_request(basicauth_data, self._password_grant_auth_header()) response.content.decode('utf-8') - self.assertEqual('invalid_client' in response.content.decode('utf-8'), - False, - msg='Client authentication fails using HTTP Basic Auth.') + self.assertNotIn( + 'invalid_client', + response.content.decode('utf-8'), + msg='Client authentication fails using HTTP Basic Auth.') def test_access_token_contains_nonce(self): """ @@ -588,7 +589,7 @@ class TokenTestCase(TestCase): response = self._post_request(post_data) response_dic = json.loads(response.content.decode('utf-8')) - id_token = JWS().verify_compact(response_dic['id_token'].encode('utf-8'), RSAKEYS) + JWS().verify_compact(response_dic['id_token'].encode('utf-8'), RSAKEYS) @override_settings(OIDC_IDTOKEN_SUB_GENERATOR='oidc_provider.tests.app.utils.fake_sub_generator') def test_custom_sub_generator(self): @@ -732,4 +733,4 @@ class TokenTestCase(TestCase): response = self._post_request(post_data) - response_dic = json.loads(response.content.decode('utf-8')) + json.loads(response.content.decode('utf-8')) diff --git a/oidc_provider/tests/test_userinfo_endpoint.py b/oidc_provider/tests/test_userinfo_endpoint.py index de95cd8..8ac52c7 100644 --- a/oidc_provider/tests/test_userinfo_endpoint.py +++ b/oidc_provider/tests/test_userinfo_endpoint.py @@ -30,10 +30,12 @@ class UserInfoTestCase(TestCase): self.user = create_fake_user() self.client = create_fake_client(response_type='code') - def _create_token(self, extra_scope=[]): + def _create_token(self, extra_scope=None): """ Generate a valid token. """ + if extra_scope is None: + extra_scope = [] scope = ['openid', 'email'] + extra_scope id_token_dic = create_id_token( @@ -60,9 +62,7 @@ class UserInfoTestCase(TestCase): """ url = reverse('oidc_provider:userinfo') - request = self.factory.post(url, - data={}, - content_type='multipart/form-data') + request = self.factory.post(url, data={}, content_type='multipart/form-data') request.META['HTTP_AUTHORIZATION'] = 'Bearer ' + access_token @@ -136,17 +136,13 @@ class UserInfoTestCase(TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(bool(response.content), True) - self.assertEqual('given_name' in response_dic, True, - msg='"given_name" claim should be in response.') - self.assertEqual('profile' in response_dic, False, - msg='"profile" claim should not be in response.') + self.assertIn('given_name', response_dic, msg='"given_name" claim should be in response.') + self.assertNotIn('profile', response_dic, msg='"profile" claim should not be in response.') # Now adding `address` scope. token = self._create_token(extra_scope=['profile', 'address']) response = self._post_request(token.access_token) response_dic = json.loads(response.content.decode('utf-8')) - self.assertEqual('address' in response_dic, True, - msg='"address" claim should be in response.') - self.assertEqual('country' in response_dic['address'], True, - msg='"country" claim should be in response.') + self.assertIn('address', response_dic, msg='"address" claim should be in response.') + self.assertIn('country', response_dic['address'], msg='"country" claim should be in response.') diff --git a/oidc_provider/views.py b/oidc_provider/views.py index 27d1499..5a4f364 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -78,7 +78,8 @@ class AuthorizeView(View): if 'select_account' in authorize.params['prompt']: # TODO: see how we can support multiple accounts for the end-user. if 'none' in authorize.params['prompt']: - raise AuthorizeError(authorize.params['redirect_uri'], 'account_selection_required', authorize.grant_type) + raise AuthorizeError( + authorize.params['redirect_uri'], 'account_selection_required', authorize.grant_type) else: django_user_logout(request) return redirect_to_login(request.get_full_path(), settings.get('OIDC_LOGIN_URL')) @@ -86,7 +87,7 @@ class AuthorizeView(View): if {'none', 'consent'}.issubset(authorize.params['prompt']): raise AuthorizeError(authorize.params['redirect_uri'], 'consent_required', authorize.grant_type) - implicit_flow_resp_types = set(['id_token', 'id_token token']) + implicit_flow_resp_types = {'id_token', 'id_token token'} allow_skipping_consent = ( authorize.client.client_type != 'public' or authorize.client.response_type in implicit_flow_resp_types) @@ -156,13 +157,15 @@ class AuthorizeView(View): authorize.validate_params() if not request.POST.get('allow'): - signals.user_decline_consent.send(self.__class__, user=request.user, client=authorize.client, scope=authorize.params['scope']) + signals.user_decline_consent.send( + self.__class__, user=request.user, client=authorize.client, scope=authorize.params['scope']) raise AuthorizeError(authorize.params['redirect_uri'], 'access_denied', authorize.grant_type) - signals.user_accept_consent.send(self.__class__, user=request.user, client=authorize.client, scope=authorize.params['scope']) + signals.user_accept_consent.send( + self.__class__, user=request.user, client=authorize.client, scope=authorize.params['scope']) # Save the user consent given to the client. authorize.set_client_user_consent() @@ -171,7 +174,7 @@ class AuthorizeView(View): return redirect(uri) - except (AuthorizeError) as error: + except AuthorizeError as error: uri = error.create_uri( authorize.params['redirect_uri'], authorize.params['state']) diff --git a/runtests.py b/runtests.py index 1557853..a8576ce 100644 --- a/runtests.py +++ b/runtests.py @@ -9,24 +9,24 @@ from django.conf import settings DEFAULT_SETTINGS = dict( - DEBUG = False, + DEBUG=False, - DATABASES = { + DATABASES={ 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': ':memory:', } }, - SITE_ID = 1, + SITE_ID=1, - MIDDLEWARE_CLASSES = [ + MIDDLEWARE_CLASSES=[ 'django.middleware.common.CommonMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', ], - TEMPLATES = [ + TEMPLATES=[ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', 'DIRS': [], @@ -42,7 +42,7 @@ DEFAULT_SETTINGS = dict( }, ], - LOGGING = { + LOGGING={ 'version': 1, 'disable_existing_loggers': False, 'handlers': { @@ -58,7 +58,7 @@ DEFAULT_SETTINGS = dict( }, }, - INSTALLED_APPS = [ + INSTALLED_APPS=[ 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', @@ -68,20 +68,20 @@ DEFAULT_SETTINGS = dict( 'oidc_provider', ], - SECRET_KEY = 'this-should-be-top-secret', + SECRET_KEY='this-should-be-top-secret', - ROOT_URLCONF = 'oidc_provider.tests.app.urls', + ROOT_URLCONF='oidc_provider.tests.app.urls', - TEMPLATE_DIRS = [ + TEMPLATE_DIRS=[ 'oidc_provider/tests/templates', ], - USE_TZ = True, + USE_TZ=True, # OIDC Provider settings. - SITE_URL = 'http://localhost:8000', - OIDC_USERINFO = 'oidc_provider.tests.app.utils.userinfo', + SITE_URL='http://localhost:8000', + OIDC_USERINFO='oidc_provider.tests.app.utils.userinfo', ) diff --git a/tox.ini b/tox.ini index ef539ff..e3b52b1 100644 --- a/tox.ini +++ b/tox.ini @@ -6,6 +6,7 @@ envlist= py34-django{17,18,19,110,111}, py35-django{18,19,110,111}, py36-django{18,19,110,111}, + flake8 [testenv] @@ -30,3 +31,9 @@ commands= commands= coverage report -m + +[testenv:flake8] +basepython=python +deps=flake8 +commands = + flake8 --max-line-length=120 From 6bb42a17317b34906c31489bf8cbf51f13a1b5e6 Mon Sep 17 00:00:00 2001 From: Wojciech Bartosiak Date: Wed, 23 Aug 2017 14:01:32 +0200 Subject: [PATCH 15/71] removed tab char --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0951b8..21447c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ All notable changes to this project will be documented in this file. ### [0.5.2] - 2017-08-22 ##### Fixed -- Fix infinite login loop if "prompt=login" (#198) +- Fix infinite login loop if "prompt=login" (#198) - Fix Django 2.0 deprecation warnings (#185) From bc3a4a2b9f15c8583718c5033d944147f2d46d27 Mon Sep 17 00:00:00 2001 From: Wojciech Bartosiak Date: Wed, 23 Aug 2017 15:30:47 +0200 Subject: [PATCH 16/71] Update CHANGELOG --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21447c5..d377aae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. ### [Unreleased] +##### Added +- Add pep8 compliance and checker ### [0.5.2] - 2017-08-22 From 6beb1865400bd75998ca0ed48d38c9ae0e16163f Mon Sep 17 00:00:00 2001 From: Monte Hellawell Date: Thu, 9 Nov 2017 10:57:22 +0000 Subject: [PATCH 17/71] Add owner field to Client (#211) * Add owner field to Client * Add related_name to client owner --- oidc_provider/admin.py | 5 ++-- oidc_provider/migrations/0023_client_owner.py | 23 +++++++++++++++++++ oidc_provider/models.py | 1 + 3 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 oidc_provider/migrations/0023_client_owner.py diff --git a/oidc_provider/admin.py b/oidc_provider/admin.py index 9197849..71542b5 100644 --- a/oidc_provider/admin.py +++ b/oidc_provider/admin.py @@ -52,8 +52,8 @@ class ClientAdmin(admin.ModelAdmin): fieldsets = [ [_(u''), { 'fields': ( - 'name', 'client_type', 'response_type', '_redirect_uris', 'jwt_alg', 'require_consent', - 'reuse_consent'), + 'name', 'owner', 'client_type', 'response_type', '_redirect_uris', 'jwt_alg', + 'require_consent', 'reuse_consent'), }], [_(u'Credentials'), { 'fields': ('client_id', 'client_secret'), @@ -69,6 +69,7 @@ class ClientAdmin(admin.ModelAdmin): list_display = ['name', 'client_id', 'response_type', 'date_created'] readonly_fields = ['date_created'] search_fields = ['name'] + raw_id_fields = ['owner'] @admin.register(Code) diff --git a/oidc_provider/migrations/0023_client_owner.py b/oidc_provider/migrations/0023_client_owner.py new file mode 100644 index 0000000..b6d214d --- /dev/null +++ b/oidc_provider/migrations/0023_client_owner.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11 on 2017-11-08 21:43 +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', '0022_auto_20170331_1626'), + ] + + operations = [ + migrations.AddField( + model_name='client', + name='owner', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='oidc_clients_set', to=settings.AUTH_USER_MODEL, verbose_name='Owner'), + ), + ] diff --git a/oidc_provider/models.py b/oidc_provider/models.py index 3a11fb4..9f78c0d 100644 --- a/oidc_provider/models.py +++ b/oidc_provider/models.py @@ -33,6 +33,7 @@ JWT_ALGS = [ class Client(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_clients_set') client_type = models.CharField( max_length=30, choices=CLIENT_TYPE_CHOICES, From 65c6cc6fec53d67879d738dce38fe1022dbf91b3 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Thu, 9 Nov 2017 12:05:20 +0100 Subject: [PATCH 18/71] Fixed client id retrieval when aud is a list of str. (#210) * Fixed client id retrievel when aud is a list of str. * Split tests. --- oidc_provider/lib/utils/token.py | 7 ++++- .../tests/test_end_session_endpoint.py | 29 +++++++++++++++---- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/oidc_provider/lib/utils/token.py b/oidc_provider/lib/utils/token.py index 73fd62e..80868db 100644 --- a/oidc_provider/lib/utils/token.py +++ b/oidc_provider/lib/utils/token.py @@ -91,7 +91,12 @@ def client_id_from_id_token(id_token): Returns a string or None. """ payload = JWT().unpack(id_token).payload() - return payload.get('aud', None) + aud = payload.get('aud', None) + if aud is None: + return None + if isinstance(aud, list): + return aud[0] + return aud def create_token(user, client, scope, id_token_dic=None): diff --git a/oidc_provider/tests/test_end_session_endpoint.py b/oidc_provider/tests/test_end_session_endpoint.py index 651c70a..636af8b 100644 --- a/oidc_provider/tests/test_end_session_endpoint.py +++ b/oidc_provider/tests/test_end_session_endpoint.py @@ -30,21 +30,40 @@ class EndSessionTestCase(TestCase): self.url = reverse('oidc_provider:end-session') - def test_redirects(self): + def test_redirects_when_aud_is_str(self): query_params = { 'post_logout_redirect_uri': self.LOGOUT_URL, } response = self.client.get(self.url, query_params) - # With no id_token the OP MUST NOT redirect to the requested redirect_uri. - self.assertRedirects(response, settings.get('OIDC_LOGIN_URL'), fetch_redirect_response=False) + # With no id_token the OP MUST NOT redirect to the requested + # redirect_uri. + self.assertRedirects( + response, settings.get('OIDC_LOGIN_URL'), + fetch_redirect_response=False) - id_token_dic = create_id_token(user=self.user, aud=self.oidc_client.client_id) + id_token_dic = create_id_token( + user=self.user, aud=self.oidc_client.client_id) id_token = encode_id_token(id_token_dic, self.oidc_client) query_params['id_token_hint'] = id_token response = self.client.get(self.url, query_params) - self.assertRedirects(response, self.LOGOUT_URL, fetch_redirect_response=False) + self.assertRedirects( + response, self.LOGOUT_URL, fetch_redirect_response=False) + + def test_redirects_when_aud_is_list(self): + """Check with 'aud' containing a list of str.""" + query_params = { + 'post_logout_redirect_uri': self.LOGOUT_URL, + } + id_token_dic = create_id_token( + user=self.user, aud=self.oidc_client.client_id) + id_token_dic['aud'] = [id_token_dic['aud']] + id_token = encode_id_token(id_token_dic, self.oidc_client) + query_params['id_token_hint'] = id_token + response = self.client.get(self.url, query_params) + self.assertRedirects( + response, self.LOGOUT_URL, fetch_redirect_response=False) @mock.patch(settings.get('OIDC_AFTER_END_SESSION_HOOK')) def test_call_post_end_session_hook(self, hook_function): From 43d990c04d78b339449cbdfb092231f493ffa25d Mon Sep 17 00:00:00 2001 From: Nicolas Iooss Date: Thu, 14 Dec 2017 18:28:55 +0100 Subject: [PATCH 19/71] Use request.user.is_authenticated as a bool with recent Django (#216) Django 1.10 changed request.user.is_authenticated from a function to a boolean and Django 2.0 dropped the backward compatibility. In order to use django-oidc-provider with Django 2.0, AuthorizeView needs to handle request.user.is_authenticated as a boolean. --- oidc_provider/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oidc_provider/views.py b/oidc_provider/views.py index 72d941e..6b66f9b 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -65,7 +65,7 @@ class AuthorizeView(View): try: authorize.validate_params() - if request.user.is_authenticated(): + if (request.user.is_authenticated if django.VERSION >= (1, 10) else request.user.is_authenticated()): # Check if there's a hook setted. hook_resp = settings.get('OIDC_AFTER_USERLOGIN_HOOK', import_str=True)( request=request, user=request.user, From bb218dbc566fce9546f87ad16103b7fb28feeafb Mon Sep 17 00:00:00 2001 From: Reinout van Rees Date: Thu, 14 Dec 2017 18:30:46 +0100 Subject: [PATCH 20/71] Sphinx documentation fixes (#219) * Small wording change + fix in example template code * Added note about UserConsent not being in the admin * Mostly spelling corrections and phrasing changes * Moved template context explation from the settings to the templates page * Changed wording * Changed wording --- docs/index.rst | 6 ++--- docs/sections/accesstokens.rst | 14 ++++++------ docs/sections/contribute.rst | 6 ++--- docs/sections/examples.rst | 2 +- docs/sections/installation.rst | 10 ++++---- docs/sections/oauth2.rst | 2 +- docs/sections/relyingparties.rst | 7 +++--- docs/sections/settings.rst | 39 ++++++++++---------------------- docs/sections/templates.rst | 19 ++++++++++++++-- docs/sections/userconsent.rst | 3 +++ 10 files changed, 56 insertions(+), 52 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 1a15854..ae586a8 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,7 +1,7 @@ Welcome to Django OIDC Provider Documentation! ============================================== -This tiny (but powerful!) package can help you providing out of the box all the endpoints, data and logic needed to add OpenID Connect capabilities to your Django projects. And as a side effect a fair implementation of OAuth2.0 too. Covers Authorization Code, Implicit and Hybrid flows. +This tiny (but powerful!) package can help you to provide out of the box all the endpoints, data and logic needed to add OpenID Connect capabilities to your Django projects. And as a side effect a fair implementation of OAuth2.0 too. Covers Authorization Code, Implicit and Hybrid flows. Also implements the following specifications: @@ -15,8 +15,8 @@ Also implements the following specifications: Before getting started there are some important things that you should know: -* Despite that implementation MUST support TLS. You can make request without using SSL. There is no control on that. -* Supports only for requesting Claims using Scope values. +* Despite that implementation MUST support TLS, you *can* make request without using SSL. There is no control on that. +* Supports only requesting Claims using Scope values, so you cannot request individual Claims. * If you enable the Resource Owner Password Credentials Grant, you MUST implement protection against brute force attacks on the token endpoint -------------------------------------------------------------------------------- diff --git a/docs/sections/accesstokens.rst b/docs/sections/accesstokens.rst index 0e000aa..0472853 100644 --- a/docs/sections/accesstokens.rst +++ b/docs/sections/accesstokens.rst @@ -3,11 +3,11 @@ Access Tokens ############# -At the end of the login process, an access token is generated. This access token is the thing that's passed along with every API call (e.g. userinfo endpoint) as proof that the call was made by a specific person from a specific app. +At the end of the login process, an access token is generated. This access token is the thing that is passed along with every API call to the openid connect server (e.g. userinfo endpoint) as proof that the call was made by a specific person from a specific app. -Access tokens generally have a lifetime of only a couple of hours, you can use ``OIDC_TOKEN_EXPIRE`` to set custom expiration that suit your needs. +Access tokens generally have a lifetime of only a couple of hours. You can use ``OIDC_TOKEN_EXPIRE`` to set a custom expiration time that suits your needs. -Obtaining an Access token +Obtaining an Access Token ========================= Go to the admin site and create a confidential client with ``response_type = code`` and ``redirect_uri = http://example.org/``. @@ -20,7 +20,7 @@ In the redirected URL you should have a ``code`` parameter included as query str http://example.org/?code=b9cedb346ee04f15ab1d3ac13da92002&state=123123 -We use ``code`` value to obtain ``access_token`` and ``refresh_token``:: +We use the ``code`` value to obtain ``access_token`` and ``refresh_token``:: curl -X POST \ -H "Cache-Control: no-cache" \ @@ -42,7 +42,7 @@ Example response:: "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6..." } -Then you can grab the access token and ask user data by doing a GET request to the ``/userinfo`` endpoint:: +Then you can grab the access token and ask for user data by doing a GET request to the ``/userinfo`` endpoint:: curl -X GET \ -H "Cache-Control: no-cache" \ @@ -51,9 +51,9 @@ Then you can grab the access token and ask user data by doing a GET request to t Expiration and Refresh of Access Tokens ======================================= -If you receive a ``401 Unauthorized`` status when issuing access token probably means that has expired. +If you receive a ``401 Unauthorized`` status when using the access token, this probably means that your access token has expired. -The RP application obtains a new access token by sending a POST request to the ``/token`` endpoint with the following request parameters:: +The RP application can request a new access token by using the refresh token. Send a POST request to the ``/token`` endpoint with the following request parameters:: curl -X POST \ -H "Cache-Control: no-cache" \ diff --git a/docs/sections/contribute.rst b/docs/sections/contribute.rst index 091d28a..f87b59d 100644 --- a/docs/sections/contribute.rst +++ b/docs/sections/contribute.rst @@ -7,7 +7,7 @@ We love contributions, so please feel free to fix bugs, improve things, provide * Fork the project. * Make your feature addition or bug fix. -* Add tests for it inside ``oidc_provider/tests``. Then run all and ensure everything is OK (read docs for how to test in all envs). +* Add tests for it inside ``oidc_provider/tests``. Then run all tests and ensure everything is OK (see the section below on how to test in all envs). * Send pull request to the specific version branch. Running Tests @@ -24,7 +24,7 @@ Use `tox `_ for running tests in each of the e # Run single test file. $ python runtests.py oidc_provider.tests.test_authorize_endpoint -Also tests run on every commit to the project, we use `travis `_ for this. +We also use `travis `_ to automatically test every commit to the project, Improve Documentation ===================== @@ -34,4 +34,4 @@ We use `Sphinx `_ for generate this documentation. I * Install Sphinx (``pip install sphinx``) and the auto-build tool (``pip install sphinx-autobuild``). * Move inside the docs folder. ``cd docs/`` * Generate and watch docs by running ``sphinx-autobuild . _build/``. -* Open ``http://127.0.0.1:8000`` on a browser. +* Open ``http://127.0.0.1:8000`` in a browser. diff --git a/docs/sections/examples.rst b/docs/sections/examples.rst index d1e8254..a8f5fe6 100644 --- a/docs/sections/examples.rst +++ b/docs/sections/examples.rst @@ -15,7 +15,7 @@ You can use the example project code to run your OIDC Provider at ``localhost:80 Go to the admin site and create a public client with a response_type ``id_token token`` and a redirect_uri ``http://localhost:3000``. .. note:: - Remember to create at least one **RSA Key** for the server. ``python manage.py creatersakey`` + Remember to create at least one **RSA Key** for the server with ``python manage.py creatersakey`` **02. Create the client** diff --git a/docs/sections/installation.rst b/docs/sections/installation.rst index ebfe61e..3314d6a 100644 --- a/docs/sections/installation.rst +++ b/docs/sections/installation.rst @@ -12,13 +12,13 @@ Requirements Quick Installation ================== -If you want to get started fast see our ``/example_project`` folder. +If you want to get started fast see our ``/example_project`` folder in your local installation. Or look at it `on github `_. Install the package using pip:: $ pip install django-oidc-provider -Add it to your apps:: +Add it to your apps in your project's django settings:: INSTALLED_APPS = ( 'django.contrib.admin', @@ -31,7 +31,7 @@ Add it to your apps:: # ... ) -Add the provider urls:: +Include our urls to your project's ``urls.py``:: urlpatterns = patterns('', # ... @@ -39,11 +39,11 @@ Add the provider urls:: # ... ) -Generate server RSA key and run migrations (if you don't):: +Run the migrations and generate a server RSA key:: $ python manage.py migrate $ python manage.py creatersakey -Add required variables to your project settings:: +Add this required variable to your project's django settings:: LOGIN_URL = '/accounts/login/' diff --git a/docs/sections/oauth2.rst b/docs/sections/oauth2.rst index bd8f545..b780b45 100644 --- a/docs/sections/oauth2.rst +++ b/docs/sections/oauth2.rst @@ -3,7 +3,7 @@ OAuth2 Server ############# -Because OIDC is a layer on top of the OAuth 2.0 protocol, this package gives you a simple but effective OAuth2 server that you can use not only for logging in your users on multiple platforms, also to protect some resources you want to expose. +Because OIDC is a layer on top of the OAuth 2.0 protocol, this package also gives you a simple but effective OAuth2 server that you can use not only for logging in your users on multiple platforms, but also to protect other resources you want to expose. Protecting Views ================ diff --git a/docs/sections/relyingparties.rst b/docs/sections/relyingparties.rst index d4477e4..d99497a 100644 --- a/docs/sections/relyingparties.rst +++ b/docs/sections/relyingparties.rst @@ -3,8 +3,9 @@ Relying Parties ############### -Relying Parties (RP) creation it's up to you. This is because is out of the scope in the core implementation of OIDC. -So, there are different ways to create your Clients (RP). By displaying a HTML form or maybe if you have internal thrusted Clients you can create them programatically. +Relying Parties (RP) creation is up to you. This is because is out of the scope in the core implementation of OIDC. +So, there are different ways to create your Clients (RP). By displaying a HTML form or maybe if you have internal trusted Clients you can create them programatically. +Out of the box, django-oidc-provider enables you to create them by hand in the django admin. OAuth defines two client types, based on their ability to maintain the confidentiality of their client credentials: @@ -61,4 +62,4 @@ You can create a Client programmatically with Django shell ``python manage.py sh >>> c = Client(name='Some Client', client_id='123', client_secret='456', response_type='code', redirect_uris=['http://example.com/']) >>> c.save() -`Read more about client creation from OAuth2 spec `_ +`Read more about client creation in the OAuth2 spec `_ diff --git a/docs/sections/settings.rst b/docs/sections/settings.rst index 82d1344..651f914 100644 --- a/docs/sections/settings.rst +++ b/docs/sections/settings.rst @@ -3,12 +3,12 @@ Settings ######## -Customize your provider so fit your project needs. +Customize django-oidc-provider so that it fits your project's needs. OIDC_LOGIN_URL ============== -OPTIONAL. ``str``. Used to log the user in. By default Django's ``LOGIN_URL`` will be used. `Read more in Django docs `_ +OPTIONAL. ``str``. Used to log the user in. By default Django's ``LOGIN_URL`` will be used. `Read more in the Django docs `_ ``str``. Default is ``/accounts/login/`` (Django's ``LOGIN_URL``). @@ -17,7 +17,7 @@ SITE_URL OPTIONAL. ``str``. The OP server url. -If not specified will be automatically generated using ``request.scheme`` and ``request.get_host()``. +If not specified, it will be automatically generated using ``request.scheme`` and ``request.get_host()``. For example ``http://localhost:8000``. @@ -34,7 +34,7 @@ Default is:: Return ``None`` if you want to continue with the flow. The typical situation will be checking some state of the user or maybe redirect him somewhere. -With request you have access to all OIDC parameters. Remember that if you redirect the user to another place then you need to take him back to the authorize endpoint (use ``request.get_full_path()`` as the value for a "next" parameter). +With ``request`` you have access to all OIDC parameters. Remember that if you redirect the user to another place then you need to take him back to the authorize endpoint (use ``request.get_full_path()`` as the value for a "next" parameter). OIDC_AFTER_END_SESSION_HOOK =========================== @@ -106,35 +106,35 @@ Default is:: OIDC_SESSION_MANAGEMENT_ENABLE ============================== -OPTIONAL. ``bool``. Enables OpenID Connect Session Management 1.0 in your provider. Read :ref:`sessionmanagement` section. +OPTIONAL. ``bool``. Enables OpenID Connect Session Management 1.0 in your provider. See the :ref:`sessionmanagement` section. Default is ``False``. OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY =========================================== -OPTIONAL. Supply a fixed string to use as browser-state key for unauthenticated clients. Read :ref:`sessionmanagement` section. +OPTIONAL. Supply a fixed string to use as browser-state key for unauthenticated clients. See the :ref:`sessionmanagement` section. Default is a string generated at startup. OIDC_SKIP_CONSENT_EXPIRE ======================== -OPTIONAL. ``int``. User consent expiration after been granted. +OPTIONAL. ``int``. How soon User Consent expires after being granted. Expressed in days. Default is ``30*3``. OIDC_TOKEN_EXPIRE ================= -OPTIONAL. ``int``. Token object (access token) expiration after been created. +OPTIONAL. ``int``. Token object (access token) expiration after being created. Expressed in seconds. Default is ``60*60``. OIDC_USERINFO ============= -OPTIONAL. ``str``. A string with the location of your function. Read :ref:`scopesclaims` section. +OPTIONAL. ``str``. A string with the location of your function. See the :ref:`scopesclaims` section. The function receives a ``claims`` dictionary with all the standard claims and ``user`` instance. Must returns the ``claims`` dict again. @@ -155,7 +155,7 @@ Example usage:: OIDC_GRANT_TYPE_PASSWORD_ENABLE =============================== -OPTIONAL. A boolean to set whether to allow the Resource Owner Password +OPTIONAL. A boolean whether to allow the Resource Owner Password Credentials Grant. https://tools.ietf.org/html/rfc6749#section-4.3 .. important:: @@ -179,21 +179,6 @@ Default is:: 'error': 'oidc_provider/error.html' } -The following contexts will be passed to the ``authorize`` and ``error`` templates respectively:: +See the :ref:`templates` section. - # For authorize template - { - 'client': 'an instance of Client for the auth request', - 'hidden_inputs': 'a rendered html with all the hidden inputs needed for AuthorizeEndpoint', - 'params': 'a dict containing the params in the auth request', - 'scopes': 'a list of scopes' - } - - # For error template - { - 'error': 'string stating the error', - 'description': 'string stating description of the error' - } - -.. note:: - The templates that are not specified here will use the default ones. +The templates that are not specified here will use the default ones. diff --git a/docs/sections/templates.rst b/docs/sections/templates.rst index bd9cef5..bb1f5fa 100644 --- a/docs/sections/templates.rst +++ b/docs/sections/templates.rst @@ -4,7 +4,7 @@ Templates ######### Add your own templates files inside a folder named ``templates/oidc_provider/``. -You can copy the sample html here and edit them with your own styles. +You can copy the sample html files here and customize them with your own style. **authorize.html**:: @@ -19,7 +19,7 @@ You can copy the sample html here and edit them with your own styles. {{ hidden_inputs }}
    - {% for scope in params.scope %} + {% for scope in scopes %}
  • {{ scope.name }}
    {{ scope.description }}
  • {% endfor %}
@@ -36,3 +36,18 @@ You can copy the sample html here and edit them with your own styles. You can also customize paths to your custom templates by putting them in ``OIDC_TEMPLATES`` in the settings. +The following contexts will be passed to the ``authorize`` and ``error`` templates respectively:: + + # For authorize template + { + 'client': 'an instance of Client for the auth request', + 'hidden_inputs': 'a rendered html with all the hidden inputs needed for AuthorizeEndpoint', + 'params': 'a dict containing the params in the auth request', + 'scopes': 'a list of scopes' + } + + # For error template + { + 'error': 'string stating the error', + 'description': 'string stating description of the error' + } diff --git a/docs/sections/userconsent.rst b/docs/sections/userconsent.rst index bc74025..2033055 100644 --- a/docs/sections/userconsent.rst +++ b/docs/sections/userconsent.rst @@ -9,6 +9,9 @@ The package store some information after the user grant access to some client. F >>> UserConsent.objects.filter(user__email='some@email.com') [] +Note: the ``UserConsent`` model is not included in the admin. + + Properties ========== From 8c736b8b08a097d7974906473a11978911750061 Mon Sep 17 00:00:00 2001 From: dhrp Date: Thu, 14 Dec 2017 18:03:15 +0100 Subject: [PATCH 21/71] Made token and token_refresh endpoint return requested claims. --- oidc_provider/lib/claims.py | 7 +++---- oidc_provider/lib/utils/token.py | 10 ++++++++-- oidc_provider/tests/app/utils.py | 1 + oidc_provider/tests/test_claims.py | 2 +- oidc_provider/tests/test_token_endpoint.py | 11 +++++++++-- oidc_provider/views.py | 2 +- 6 files changed, 23 insertions(+), 10 deletions(-) diff --git a/oidc_provider/lib/claims.py b/oidc_provider/lib/claims.py index d4af2ad..d5a0947 100644 --- a/oidc_provider/lib/claims.py +++ b/oidc_provider/lib/claims.py @@ -16,12 +16,11 @@ STANDARD_CLAIMS = { class ScopeClaims(object): - def __init__(self, token): - self.user = token.user + def __init__(self, user, scope): + self.user = user claims = copy.deepcopy(STANDARD_CLAIMS) self.userinfo = settings.get('OIDC_USERINFO', import_str=True)(claims, self.user) - self.scopes = token.scope - self.client = token.client + self.scopes = scope def create_response_dic(self): """ diff --git a/oidc_provider/lib/utils/token.py b/oidc_provider/lib/utils/token.py index 80868db..8700a4f 100644 --- a/oidc_provider/lib/utils/token.py +++ b/oidc_provider/lib/utils/token.py @@ -10,6 +10,7 @@ from jwkest.jws import JWS from jwkest.jwt import JWT from oidc_provider.lib.utils.common import get_issuer +from oidc_provider.lib.claims import StandardScopeClaims from oidc_provider.models import ( Code, RSAKey, @@ -52,8 +53,13 @@ def create_id_token(user, aud, nonce='', at_hash='', request=None, scope=None): if at_hash: dic['at_hash'] = at_hash - if ('email' in scope) and getattr(user, 'email', None): - dic['email'] = user.email + if settings.get('OIDC_EXTRA_SCOPE_CLAIMS'): + custom_claims = settings.get('OIDC_EXTRA_SCOPE_CLAIMS', import_str=True)(user, scope) + claims = custom_claims.create_response_dic() + else: + claims = StandardScopeClaims(user=user, scope=scope).create_response_dic() + + dic.update(claims) # modifies dic, adding all requested claims processing_hook = settings.get('OIDC_IDTOKEN_PROCESSING_HOOK') diff --git a/oidc_provider/tests/app/utils.py b/oidc_provider/tests/app/utils.py index 4f824ec..98b4d6b 100644 --- a/oidc_provider/tests/app/utils.py +++ b/oidc_provider/tests/app/utils.py @@ -96,6 +96,7 @@ def userinfo(claims, user): claims['family_name'] = 'Doe' claims['name'] = '{0} {1}'.format(claims['given_name'], claims['family_name']) claims['email'] = user.email + claims['email_verified'] = True claims['address']['country'] = 'Argentina' return claims diff --git a/oidc_provider/tests/test_claims.py b/oidc_provider/tests/test_claims.py index c1ac794..f209990 100644 --- a/oidc_provider/tests/test_claims.py +++ b/oidc_provider/tests/test_claims.py @@ -15,7 +15,7 @@ class ClaimsTestCase(TestCase): self.scopes = ['openid', 'address', 'email', 'phone', 'profile'] self.client = create_fake_client('code') self.token = create_fake_token(self.user, self.scopes, self.client) - self.scopeClaims = ScopeClaims(self.token) + self.scopeClaims = ScopeClaims(self.token.user, self.token.scope) def test_empty_standard_claims(self): for v in [v for k, v in STANDARD_CLAIMS.items() if k != 'address']: diff --git a/oidc_provider/tests/test_token_endpoint.py b/oidc_provider/tests/test_token_endpoint.py index 49b7598..a151e1a 100644 --- a/oidc_provider/tests/test_token_endpoint.py +++ b/oidc_provider/tests/test_token_endpoint.py @@ -300,13 +300,14 @@ class TokenTestCase(TestCase): def test_scope_is_ignored_for_auth_code(self): """ Scope is ignored for token respones to auth code grant type. + This comes down to that the scopes requested in authorize are returned. """ SIGKEYS = self._get_keys() - for code_scope in [['openid'], ['openid', 'email']]: + for code_scope in [['openid'], ['openid', 'email'], ['openid', 'profile']]: code = self._create_code(code_scope) post_data = self._auth_code_post_data( - code=code.code, scope=['openid', 'profile']) + code=code.code, scope=code_scope) response = self._post_request(post_data) response_dic = json.loads(response.content.decode('utf-8')) @@ -317,9 +318,15 @@ class TokenTestCase(TestCase): if 'email' in code_scope: self.assertIn('email', id_token) + self.assertIn('email_verified', id_token) else: self.assertNotIn('email', id_token) + if 'profile' in code_scope: + self.assertIn('given_name', id_token) + else: + self.assertNotIn('given_name', id_token) + def test_refresh_token(self): """ A request to the Token Endpoint can also use a Refresh Token diff --git a/oidc_provider/views.py b/oidc_provider/views.py index 72d941e..eea9729 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -234,7 +234,7 @@ def userinfo(request, *args, **kwargs): 'sub': token.id_token.get('sub'), } - standard_claims = StandardScopeClaims(token) + standard_claims = StandardScopeClaims(user=token.user, scope=token.scope) dic.update(standard_claims.create_response_dic()) if settings.get('OIDC_EXTRA_SCOPE_CLAIMS'): From 900cc9e5dfd8e759a9aa895c96826b4656a0ced5 Mon Sep 17 00:00:00 2001 From: dhrp Date: Fri, 15 Dec 2017 09:29:49 +0100 Subject: [PATCH 22/71] Now passing along the token to create_id_token function. --- oidc_provider/lib/claims.py | 7 ++++--- oidc_provider/lib/endpoints/authorize.py | 1 + oidc_provider/lib/endpoints/token.py | 3 +++ oidc_provider/lib/utils/token.py | 6 +++--- oidc_provider/tests/test_claims.py | 2 +- oidc_provider/tests/test_end_session_endpoint.py | 7 +++++-- oidc_provider/tests/test_userinfo_endpoint.py | 12 +++++++----- oidc_provider/tests/test_utils.py | 8 +++++--- oidc_provider/views.py | 2 +- 9 files changed, 30 insertions(+), 18 deletions(-) diff --git a/oidc_provider/lib/claims.py b/oidc_provider/lib/claims.py index d5a0947..d4af2ad 100644 --- a/oidc_provider/lib/claims.py +++ b/oidc_provider/lib/claims.py @@ -16,11 +16,12 @@ STANDARD_CLAIMS = { class ScopeClaims(object): - def __init__(self, user, scope): - self.user = user + def __init__(self, token): + self.user = token.user claims = copy.deepcopy(STANDARD_CLAIMS) self.userinfo = settings.get('OIDC_USERINFO', import_str=True)(claims, self.user) - self.scopes = scope + self.scopes = token.scope + self.client = token.client def create_response_dic(self): """ diff --git a/oidc_provider/lib/endpoints/authorize.py b/oidc_provider/lib/endpoints/authorize.py index 3313bd6..3c8e908 100644 --- a/oidc_provider/lib/endpoints/authorize.py +++ b/oidc_provider/lib/endpoints/authorize.py @@ -155,6 +155,7 @@ class AuthorizeEndpoint(object): kwargs = { 'user': self.request.user, 'aud': self.client.client_id, + 'token': token, 'nonce': self.params['nonce'], 'request': self.request, 'scope': self.params['scope'], diff --git a/oidc_provider/lib/endpoints/token.py b/oidc_provider/lib/endpoints/token.py index 5a6b0af..7962bd6 100644 --- a/oidc_provider/lib/endpoints/token.py +++ b/oidc_provider/lib/endpoints/token.py @@ -164,6 +164,7 @@ class TokenEndpoint(object): id_token_dic = create_id_token( user=self.user, aud=self.client.client_id, + token=token, nonce='self.code.nonce', at_hash=token.at_hash, request=self.request, @@ -193,6 +194,7 @@ class TokenEndpoint(object): id_token_dic = create_id_token( user=self.code.user, aud=self.client.client_id, + token=token, nonce=self.code.nonce, at_hash=token.at_hash, request=self.request, @@ -237,6 +239,7 @@ class TokenEndpoint(object): id_token_dic = create_id_token( user=self.token.user, aud=self.client.client_id, + token=token, nonce=None, at_hash=token.at_hash, request=self.request, diff --git a/oidc_provider/lib/utils/token.py b/oidc_provider/lib/utils/token.py index 8700a4f..600f2c2 100644 --- a/oidc_provider/lib/utils/token.py +++ b/oidc_provider/lib/utils/token.py @@ -19,7 +19,7 @@ from oidc_provider.models import ( from oidc_provider import settings -def create_id_token(user, aud, nonce='', at_hash='', request=None, scope=None): +def create_id_token(user, aud, token, nonce='', at_hash='', request=None, scope=None): """ Creates the id_token dictionary. See: http://openid.net/specs/openid-connect-core-1_0.html#IDToken @@ -54,10 +54,10 @@ def create_id_token(user, aud, nonce='', at_hash='', request=None, scope=None): dic['at_hash'] = at_hash if settings.get('OIDC_EXTRA_SCOPE_CLAIMS'): - custom_claims = settings.get('OIDC_EXTRA_SCOPE_CLAIMS', import_str=True)(user, scope) + custom_claims = settings.get('OIDC_EXTRA_SCOPE_CLAIMS', import_str=True)(token) claims = custom_claims.create_response_dic() else: - claims = StandardScopeClaims(user=user, scope=scope).create_response_dic() + claims = StandardScopeClaims(token).create_response_dic() dic.update(claims) # modifies dic, adding all requested claims diff --git a/oidc_provider/tests/test_claims.py b/oidc_provider/tests/test_claims.py index f209990..c1ac794 100644 --- a/oidc_provider/tests/test_claims.py +++ b/oidc_provider/tests/test_claims.py @@ -15,7 +15,7 @@ class ClaimsTestCase(TestCase): self.scopes = ['openid', 'address', 'email', 'phone', 'profile'] self.client = create_fake_client('code') self.token = create_fake_token(self.user, self.scopes, self.client) - self.scopeClaims = ScopeClaims(self.token.user, self.token.scope) + self.scopeClaims = ScopeClaims(self.token) def test_empty_standard_claims(self): for v in [v for k, v in STANDARD_CLAIMS.items() if k != 'address']: diff --git a/oidc_provider/tests/test_end_session_endpoint.py b/oidc_provider/tests/test_end_session_endpoint.py index 636af8b..586a00c 100644 --- a/oidc_provider/tests/test_end_session_endpoint.py +++ b/oidc_provider/tests/test_end_session_endpoint.py @@ -3,6 +3,7 @@ from django.core.urlresolvers import reverse from django.test import TestCase from oidc_provider.lib.utils.token import ( + create_token, create_id_token, encode_id_token, ) @@ -41,8 +42,9 @@ class EndSessionTestCase(TestCase): response, settings.get('OIDC_LOGIN_URL'), fetch_redirect_response=False) + token = create_token(self.user, self.oidc_client, []) id_token_dic = create_id_token( - user=self.user, aud=self.oidc_client.client_id) + user=self.user, aud=self.oidc_client.client_id, token=token) id_token = encode_id_token(id_token_dic, self.oidc_client) query_params['id_token_hint'] = id_token @@ -56,8 +58,9 @@ class EndSessionTestCase(TestCase): query_params = { 'post_logout_redirect_uri': self.LOGOUT_URL, } + token = create_token(self.user, self.oidc_client, []) id_token_dic = create_id_token( - user=self.user, aud=self.oidc_client.client_id) + user=self.user, aud=self.oidc_client.client_id, token=token) id_token_dic['aud'] = [id_token_dic['aud']] id_token = encode_id_token(id_token_dic, self.oidc_client) query_params['id_token_hint'] = id_token diff --git a/oidc_provider/tests/test_userinfo_endpoint.py b/oidc_provider/tests/test_userinfo_endpoint.py index 8ac52c7..62ead94 100644 --- a/oidc_provider/tests/test_userinfo_endpoint.py +++ b/oidc_provider/tests/test_userinfo_endpoint.py @@ -38,18 +38,20 @@ class UserInfoTestCase(TestCase): extra_scope = [] scope = ['openid', 'email'] + extra_scope + token = create_token( + user=self.user, + client=self.client, + scope=scope) + id_token_dic = create_id_token( user=self.user, aud=self.client.client_id, + token=token, nonce=FAKE_NONCE, scope=scope, ) - token = create_token( - user=self.user, - client=self.client, - id_token_dic=id_token_dic, - scope=scope) + token.id_token=id_token_dic token.save() return token diff --git a/oidc_provider/tests/test_utils.py b/oidc_provider/tests/test_utils.py index b09ff46..dd65cd6 100644 --- a/oidc_provider/tests/test_utils.py +++ b/oidc_provider/tests/test_utils.py @@ -8,8 +8,8 @@ from django.utils import timezone from mock import mock from oidc_provider.lib.utils.common import get_issuer, get_browser_state_or_default -from oidc_provider.lib.utils.token import create_id_token -from oidc_provider.tests.app.utils import create_fake_user +from oidc_provider.lib.utils.token import create_token, create_id_token +from oidc_provider.tests.app.utils import create_fake_user, create_fake_client class Request(object): @@ -67,7 +67,9 @@ class TokenTest(TestCase): start_time = int(time.time()) login_timestamp = start_time - 1234 self.user.last_login = timestamp_to_datetime(login_timestamp) - id_token_data = create_id_token(self.user, aud='test-aud') + client = create_fake_client("code") + token = create_token(self.user, client, []) + id_token_data = create_id_token(self.user, aud='test-aud', token=token) iat = id_token_data['iat'] self.assertEqual(type(iat), int) self.assertGreaterEqual(iat, start_time) diff --git a/oidc_provider/views.py b/oidc_provider/views.py index eea9729..72d941e 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -234,7 +234,7 @@ def userinfo(request, *args, **kwargs): 'sub': token.id_token.get('sub'), } - standard_claims = StandardScopeClaims(user=token.user, scope=token.scope) + standard_claims = StandardScopeClaims(token) dic.update(standard_claims.create_response_dic()) if settings.get('OIDC_EXTRA_SCOPE_CLAIMS'): From 795ac322570230ed4c39a98844f0c5431f5024c5 Mon Sep 17 00:00:00 2001 From: Allisson Azevedo Date: Thu, 1 Feb 2018 14:00:57 -0300 Subject: [PATCH 23/71] Update project to support Django 2.0 --- .travis.yml | 3 ++ CHANGELOG.md | 5 +-- example_project/myapp/settings.py | 2 +- example_project/myapp/urls.py | 13 ++++---- oidc_provider/compat.py | 5 +++ oidc_provider/tests/app/urls.py | 7 ++-- .../tests/test_authorize_endpoint.py | 5 ++- .../tests/test_end_session_endpoint.py | 6 ++-- oidc_provider/tests/test_middleware.py | 5 ++- .../tests/test_provider_info_endpoint.py | 5 ++- oidc_provider/tests/test_token_endpoint.py | 5 ++- oidc_provider/tests/test_userinfo_endpoint.py | 5 ++- oidc_provider/urls.py | 5 ++- oidc_provider/views.py | 10 +++--- runtests.py | 32 +++++++++++-------- setup.py | 2 +- tox.ini | 7 ++-- 17 files changed, 80 insertions(+), 42 deletions(-) create mode 100644 oidc_provider/compat.py diff --git a/.travis.yml b/.travis.yml index 6c39c62..3d5c324 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,8 +10,11 @@ env: - DJANGO=1.9 - DJANGO=1.10 - DJANGO=1.11 + - DJANGO=2.0 matrix: exclude: + - python: "2.7" + env: DJANGO=2.0 - python: "3.5" env: DJANGO=1.7 - python: "3.6" diff --git a/CHANGELOG.md b/CHANGELOG.md index c0951b8..5b4084d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,10 @@ All notable changes to this project will be documented in this file. -### [Unreleased] +### [0.5.3] - 2018-02-01 +##### Fixed +- Update project to support Django 2.0 ### [0.5.2] - 2017-08-22 @@ -11,7 +13,6 @@ All notable changes to this project will be documented in this file. - Fix infinite login loop if "prompt=login" (#198) - Fix Django 2.0 deprecation warnings (#185) - ### [0.5.1] - 2017-07-11 ##### Changed diff --git a/example_project/myapp/settings.py b/example_project/myapp/settings.py index e97ad1d..8d531d9 100644 --- a/example_project/myapp/settings.py +++ b/example_project/myapp/settings.py @@ -29,11 +29,11 @@ MIDDLEWARE_CLASSES = [ 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'oidc_provider.middleware.SessionManagementMiddleware', ] +MIDDLEWARE = MIDDLEWARE_CLASSES TEMPLATES = [ { diff --git a/example_project/myapp/urls.py b/example_project/myapp/urls.py index 91d31fa..2b0a624 100644 --- a/example_project/myapp/urls.py +++ b/example_project/myapp/urls.py @@ -1,15 +1,16 @@ from django.contrib.auth import views as auth_views -from django.conf.urls import include, url +try: + from django.urls import include, url +except ImportError: + from django.conf.urls import include, url from django.contrib import admin from django.views.generic import TemplateView urlpatterns = [ url(r'^$', TemplateView.as_view(template_name='home.html'), name='home'), - url(r'^accounts/login/$', auth_views.login, { 'template_name': 'login.html' }, name='login'), - url(r'^accounts/logout/$', auth_views.logout, { 'next_page': '/' }, name='logout'), - + url(r'^accounts/login/$', auth_views.login, {'template_name': 'login.html'}, name='login'), + url(r'^accounts/logout/$', auth_views.logout, {'next_page': '/'}, name='logout'), url(r'^', include('oidc_provider.urls', namespace='oidc_provider')), - - url(r'^admin/', include(admin.site.urls)), + url(r'^admin/', admin.site.urls), ] diff --git a/oidc_provider/compat.py b/oidc_provider/compat.py new file mode 100644 index 0000000..13091df --- /dev/null +++ b/oidc_provider/compat.py @@ -0,0 +1,5 @@ +def get_attr_or_callable(obj, name): + target = getattr(obj, name) + if callable(target): + return target() + return target diff --git a/oidc_provider/tests/app/urls.py b/oidc_provider/tests/app/urls.py index 8c513fd..d09fad1 100644 --- a/oidc_provider/tests/app/urls.py +++ b/oidc_provider/tests/app/urls.py @@ -1,5 +1,8 @@ from django.contrib.auth import views as auth_views -from django.conf.urls import include, url +try: + from django.urls import include, url +except ImportError: + from django.conf.urls import include, url from django.contrib import admin from django.views.generic import TemplateView @@ -11,5 +14,5 @@ urlpatterns = [ url(r'^openid/', include('oidc_provider.urls', namespace='oidc_provider')), - url(r'^admin/', include(admin.site.urls)), + url(r'^admin/', admin.site.urls), ] diff --git a/oidc_provider/tests/test_authorize_endpoint.py b/oidc_provider/tests/test_authorize_endpoint.py index 361f27f..a48c1bd 100644 --- a/oidc_provider/tests/test_authorize_endpoint.py +++ b/oidc_provider/tests/test_authorize_endpoint.py @@ -13,7 +13,10 @@ from mock import patch, mock from django.contrib.auth.models import AnonymousUser from django.core.management import call_command -from django.core.urlresolvers import reverse +try: + from django.urls import reverse +except ImportError: + from django.core.urlresolvers import reverse from django.test import ( RequestFactory, override_settings, diff --git a/oidc_provider/tests/test_end_session_endpoint.py b/oidc_provider/tests/test_end_session_endpoint.py index b416762..4b880ef 100644 --- a/oidc_provider/tests/test_end_session_endpoint.py +++ b/oidc_provider/tests/test_end_session_endpoint.py @@ -1,5 +1,8 @@ from django.core.management import call_command -from django.core.urlresolvers import reverse +try: + from django.urls import reverse +except ImportError: + from django.core.urlresolvers import reverse from django.test import TestCase from oidc_provider.lib.utils.token import ( @@ -51,4 +54,3 @@ class EndSessionTestCase(TestCase): self.client.get(self.url) self.assertTrue(hook_function.called, 'OIDC_AFTER_END_SESSION_HOOK should be called') self.assertTrue(hook_function.call_count == 1, 'OIDC_AFTER_END_SESSION_HOOK should be called once but was {}'.format(hook_function.call_count)) - diff --git a/oidc_provider/tests/test_middleware.py b/oidc_provider/tests/test_middleware.py index c2a02df..75e14f8 100644 --- a/oidc_provider/tests/test_middleware.py +++ b/oidc_provider/tests/test_middleware.py @@ -1,4 +1,7 @@ -from django.conf.urls import url +try: + from django.urls import url +except ImportError: + from django.conf.urls import url from django.test import TestCase, override_settings from django.views.generic import View from mock import mock diff --git a/oidc_provider/tests/test_provider_info_endpoint.py b/oidc_provider/tests/test_provider_info_endpoint.py index 6e8da9d..2265ef6 100644 --- a/oidc_provider/tests/test_provider_info_endpoint.py +++ b/oidc_provider/tests/test_provider_info_endpoint.py @@ -1,4 +1,7 @@ -from django.core.urlresolvers import reverse +try: + from django.urls import reverse +except ImportError: + from django.core.urlresolvers import reverse from django.test import RequestFactory from django.test import TestCase diff --git a/oidc_provider/tests/test_token_endpoint.py b/oidc_provider/tests/test_token_endpoint.py index b23d4fd..254345e 100644 --- a/oidc_provider/tests/test_token_endpoint.py +++ b/oidc_provider/tests/test_token_endpoint.py @@ -9,7 +9,10 @@ except ImportError: from urllib import urlencode from django.core.management import call_command -from django.core.urlresolvers import reverse +try: + from django.urls import reverse +except ImportError: + from django.core.urlresolvers import reverse from django.test import ( RequestFactory, override_settings, diff --git a/oidc_provider/tests/test_userinfo_endpoint.py b/oidc_provider/tests/test_userinfo_endpoint.py index de95cd8..b23c3a3 100644 --- a/oidc_provider/tests/test_userinfo_endpoint.py +++ b/oidc_provider/tests/test_userinfo_endpoint.py @@ -6,7 +6,10 @@ try: except ImportError: from urllib import urlencode -from django.core.urlresolvers import reverse +try: + from django.urls import reverse +except ImportError: + from django.core.urlresolvers import reverse from django.test import RequestFactory from django.test import TestCase from django.utils import timezone diff --git a/oidc_provider/urls.py b/oidc_provider/urls.py index d501e88..93033f3 100644 --- a/oidc_provider/urls.py +++ b/oidc_provider/urls.py @@ -1,4 +1,7 @@ -from django.conf.urls import url +try: + from django.urls import url +except ImportError: + from django.conf.urls import url from django.views.decorators.csrf import csrf_exempt from oidc_provider import ( diff --git a/oidc_provider/views.py b/oidc_provider/views.py index f1b90f0..5558117 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -11,13 +11,10 @@ from django.contrib.auth.views import ( redirect_to_login, logout, ) - -import django -if django.VERSION >= (1, 11): +try: from django.urls import reverse -else: +except ImportError: from django.core.urlresolvers import reverse - from django.contrib.auth import logout as django_user_logout from django.http import JsonResponse from django.shortcuts import render @@ -28,6 +25,7 @@ from django.views.decorators.http import require_http_methods from django.views.generic import View from jwkest import long_to_base64 +from oidc_provider.compat import get_attr_or_callable from oidc_provider.lib.claims import StandardScopeClaims from oidc_provider.lib.endpoints.authorize import AuthorizeEndpoint from oidc_provider.lib.endpoints.token import TokenEndpoint @@ -65,7 +63,7 @@ class AuthorizeView(View): try: authorize.validate_params() - if request.user.is_authenticated(): + if get_attr_or_callable(request.user, 'is_authenticated'): # Check if there's a hook setted. hook_resp = settings.get('OIDC_AFTER_USERLOGIN_HOOK', import_str=True)( request=request, user=request.user, diff --git a/runtests.py b/runtests.py index 1557853..636062d 100644 --- a/runtests.py +++ b/runtests.py @@ -9,24 +9,30 @@ from django.conf import settings DEFAULT_SETTINGS = dict( - DEBUG = False, + DEBUG=False, - DATABASES = { + DATABASES={ 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': ':memory:', } }, - SITE_ID = 1, + SITE_ID=1, - MIDDLEWARE_CLASSES = [ + MIDDLEWARE_CLASSES=[ 'django.middleware.common.CommonMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', ], - TEMPLATES = [ + MIDDLEWARE=[ + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + ], + + TEMPLATES=[ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', 'DIRS': [], @@ -42,7 +48,7 @@ DEFAULT_SETTINGS = dict( }, ], - LOGGING = { + LOGGING={ 'version': 1, 'disable_existing_loggers': False, 'handlers': { @@ -58,7 +64,7 @@ DEFAULT_SETTINGS = dict( }, }, - INSTALLED_APPS = [ + INSTALLED_APPS=[ 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', @@ -68,20 +74,20 @@ DEFAULT_SETTINGS = dict( 'oidc_provider', ], - SECRET_KEY = 'this-should-be-top-secret', + SECRET_KEY='this-should-be-top-secret', - ROOT_URLCONF = 'oidc_provider.tests.app.urls', + ROOT_URLCONF='oidc_provider.tests.app.urls', - TEMPLATE_DIRS = [ + TEMPLATE_DIRS=[ 'oidc_provider/tests/templates', ], - USE_TZ = True, + USE_TZ=True, # OIDC Provider settings. - SITE_URL = 'http://localhost:8000', - OIDC_USERINFO = 'oidc_provider.tests.app.utils.userinfo', + SITE_URL='http://localhost:8000', + OIDC_USERINFO='oidc_provider.tests.app.utils.userinfo', ) diff --git a/setup.py b/setup.py index 61ce8cc..02d7f45 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) setup( name='django-oidc-provider', - version='0.5.2', + version='0.5.3', packages=find_packages(), include_package_data=True, license='MIT License', diff --git a/tox.ini b/tox.ini index ef539ff..bb1ed84 100644 --- a/tox.ini +++ b/tox.ini @@ -3,9 +3,9 @@ envlist= clean, py27-django{17,18,19,110,111}, - py34-django{17,18,19,110,111}, - py35-django{18,19,110,111}, - py36-django{18,19,110,111}, + py34-django{17,18,19,110,111,20}, + py35-django{18,19,110,111,20}, + py36-django{18,19,110,111,20}, [testenv] @@ -15,6 +15,7 @@ deps = django19: django>=1.9,<1.10 django110: django>=1.10,<1.11 django111: django>=1.11,<1.12 + django20: django>=2.0,<2.1 coverage mock From baa53d0c888f8c3f3bc86c4a7c44858ec081d620 Mon Sep 17 00:00:00 2001 From: Yannik Gartmann Date: Mon, 19 Feb 2018 16:31:30 +0100 Subject: [PATCH 24/71] Fixed wrong Object in Template See >> https://github.com/juanifioren/django-oidc-provider/blob/v0.5.x/oidc_provider/views.py#L129 --- docs/sections/templates.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sections/templates.rst b/docs/sections/templates.rst index bd9cef5..6f7eae8 100644 --- a/docs/sections/templates.rst +++ b/docs/sections/templates.rst @@ -19,7 +19,7 @@ You can copy the sample html here and edit them with your own styles. {{ hidden_inputs }}
    - {% for scope in params.scope %} + {% for scope in scopes %}
  • {{ scope.name }}
    {{ scope.description }}
  • {% endfor %}
From 72388bb117b31e682f2e1828545b3ee57d7c2e79 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Mon, 19 Feb 2018 23:41:38 -0300 Subject: [PATCH 25/71] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b4084d..944ecd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to this project will be documented in this file. -### [0.5.3] - 2018-02-01 +### Unreleased ##### Fixed - Update project to support Django 2.0 From 4f0afe27d321265e2c2dde5463a811c76073098a Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Mon, 19 Feb 2018 23:43:26 -0300 Subject: [PATCH 26/71] 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 ebfe61e..8f9a933 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`` +* Django: ``1.7`` ``1.8`` ``1.9`` ``1.10`` ``1.11`` ``2.0`` Quick Installation ================== From b32fa6c17cc446f005ec2a5ba050f53452ce44ac Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Fri, 9 Mar 2018 17:03:25 -0300 Subject: [PATCH 27/71] Bump version 0.5.3. --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 944ecd1..1326217 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. ### Unreleased +### [0.5.3] - 2018-03-09 + ##### Fixed - Update project to support Django 2.0 @@ -11,7 +13,7 @@ All notable changes to this project will be documented in this file. ##### Fixed - Fix infinite login loop if "prompt=login" (#198) -- Fix Django 2.0 deprecation warnings (#185) +- Fix Django 2.0 deprecation warnings (#185) ### [0.5.1] - 2017-07-11 From baee747c0de5360db495df1d8ee4ea0d8310b367 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Fri, 9 Mar 2018 17:05:22 -0300 Subject: [PATCH 28/71] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 944ecd1..7e7132a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. ### Unreleased +### [0.5.3] - 2018-03-09 + ##### Fixed - Update project to support Django 2.0 From d519e49acbdfc33642bc0f51cc2434d831be8b62 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Thu, 22 Mar 2018 11:45:56 -0300 Subject: [PATCH 29/71] Simplify test suit. --- .gitignore | 1 + .../{ => cases}/test_authorize_endpoint.py | 0 .../tests/{ => cases}/test_claims.py | 0 .../tests/{ => cases}/test_commands.py | 0 .../{ => cases}/test_end_session_endpoint.py | 0 .../tests/{ => cases}/test_middleware.py | 0 .../test_provider_info_endpoint.py | 0 .../tests/{ => cases}/test_settings.py | 0 .../tests/{ => cases}/test_token_endpoint.py | 0 .../{ => cases}/test_userinfo_endpoint.py | 0 oidc_provider/tests/{ => cases}/test_utils.py | 0 oidc_provider/tests/settings.py | 79 ++++++++++++ runtests.py | 120 ------------------ tox.ini | 36 +++--- 14 files changed, 99 insertions(+), 137 deletions(-) rename oidc_provider/tests/{ => cases}/test_authorize_endpoint.py (100%) rename oidc_provider/tests/{ => cases}/test_claims.py (100%) rename oidc_provider/tests/{ => cases}/test_commands.py (100%) rename oidc_provider/tests/{ => cases}/test_end_session_endpoint.py (100%) rename oidc_provider/tests/{ => cases}/test_middleware.py (100%) rename oidc_provider/tests/{ => cases}/test_provider_info_endpoint.py (100%) rename oidc_provider/tests/{ => cases}/test_settings.py (100%) rename oidc_provider/tests/{ => cases}/test_token_endpoint.py (100%) rename oidc_provider/tests/{ => cases}/test_userinfo_endpoint.py (100%) rename oidc_provider/tests/{ => cases}/test_utils.py (100%) create mode 100644 oidc_provider/tests/settings.py delete mode 100644 runtests.py diff --git a/.gitignore b/.gitignore index 2c6f875..c057419 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ src/ docs/_build/ .eggs/ .python-version +.pytest_cache/ diff --git a/oidc_provider/tests/test_authorize_endpoint.py b/oidc_provider/tests/cases/test_authorize_endpoint.py similarity index 100% rename from oidc_provider/tests/test_authorize_endpoint.py rename to oidc_provider/tests/cases/test_authorize_endpoint.py diff --git a/oidc_provider/tests/test_claims.py b/oidc_provider/tests/cases/test_claims.py similarity index 100% rename from oidc_provider/tests/test_claims.py rename to oidc_provider/tests/cases/test_claims.py diff --git a/oidc_provider/tests/test_commands.py b/oidc_provider/tests/cases/test_commands.py similarity index 100% rename from oidc_provider/tests/test_commands.py rename to oidc_provider/tests/cases/test_commands.py diff --git a/oidc_provider/tests/test_end_session_endpoint.py b/oidc_provider/tests/cases/test_end_session_endpoint.py similarity index 100% rename from oidc_provider/tests/test_end_session_endpoint.py rename to oidc_provider/tests/cases/test_end_session_endpoint.py diff --git a/oidc_provider/tests/test_middleware.py b/oidc_provider/tests/cases/test_middleware.py similarity index 100% rename from oidc_provider/tests/test_middleware.py rename to oidc_provider/tests/cases/test_middleware.py diff --git a/oidc_provider/tests/test_provider_info_endpoint.py b/oidc_provider/tests/cases/test_provider_info_endpoint.py similarity index 100% rename from oidc_provider/tests/test_provider_info_endpoint.py rename to oidc_provider/tests/cases/test_provider_info_endpoint.py diff --git a/oidc_provider/tests/test_settings.py b/oidc_provider/tests/cases/test_settings.py similarity index 100% rename from oidc_provider/tests/test_settings.py rename to oidc_provider/tests/cases/test_settings.py diff --git a/oidc_provider/tests/test_token_endpoint.py b/oidc_provider/tests/cases/test_token_endpoint.py similarity index 100% rename from oidc_provider/tests/test_token_endpoint.py rename to oidc_provider/tests/cases/test_token_endpoint.py diff --git a/oidc_provider/tests/test_userinfo_endpoint.py b/oidc_provider/tests/cases/test_userinfo_endpoint.py similarity index 100% rename from oidc_provider/tests/test_userinfo_endpoint.py rename to oidc_provider/tests/cases/test_userinfo_endpoint.py diff --git a/oidc_provider/tests/test_utils.py b/oidc_provider/tests/cases/test_utils.py similarity index 100% rename from oidc_provider/tests/test_utils.py rename to oidc_provider/tests/cases/test_utils.py diff --git a/oidc_provider/tests/settings.py b/oidc_provider/tests/settings.py new file mode 100644 index 0000000..ea61262 --- /dev/null +++ b/oidc_provider/tests/settings.py @@ -0,0 +1,79 @@ +DEBUG = False + +SECRET_KEY = 'this-should-be-top-secret' + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', + } +} + +SITE_ID = 1 + +MIDDLEWARE_CLASSES = [ + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', +] + +MIDDLEWARE = [ + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', +] + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +INSTALLED_APPS = [ + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.sites', + 'django.contrib.messages', + 'django.contrib.admin', + 'oidc_provider', +] + +ROOT_URLCONF = 'oidc_provider.tests.app.urls' + +TEMPLATE_DIRS = [ + 'oidc_provider/tests/templates', +] + +USE_TZ = True + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + }, + }, + 'loggers': { + 'oidc_provider': { + 'handlers': ['console'], + 'level': 'DEBUG', + }, + }, +} + +# OIDC Provider settings. + +SITE_URL = 'http://localhost:8000' +OIDC_USERINFO = 'oidc_provider.tests.app.utils.userinfo' diff --git a/runtests.py b/runtests.py deleted file mode 100644 index 636062d..0000000 --- a/runtests.py +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env python -import os -import sys - -import django - -from django.conf import settings - - -DEFAULT_SETTINGS = dict( - - DEBUG=False, - - DATABASES={ - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ':memory:', - } - }, - - SITE_ID=1, - - MIDDLEWARE_CLASSES=[ - 'django.middleware.common.CommonMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - ], - - MIDDLEWARE=[ - 'django.middleware.common.CommonMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - ], - - TEMPLATES=[ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ], - }, - }, - ], - - LOGGING={ - 'version': 1, - 'disable_existing_loggers': False, - 'handlers': { - 'console': { - 'class': 'logging.StreamHandler', - }, - }, - 'loggers': { - 'oidc_provider': { - 'handlers': ['console'], - 'level': os.getenv('DJANGO_LOG_LEVEL', 'DEBUG'), - }, - }, - }, - - INSTALLED_APPS=[ - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.sites', - 'django.contrib.messages', - 'django.contrib.admin', - 'oidc_provider', - ], - - SECRET_KEY='this-should-be-top-secret', - - ROOT_URLCONF='oidc_provider.tests.app.urls', - - TEMPLATE_DIRS=[ - 'oidc_provider/tests/templates', - ], - - USE_TZ=True, - - # OIDC Provider settings. - - SITE_URL='http://localhost:8000', - OIDC_USERINFO='oidc_provider.tests.app.utils.userinfo', - -) - - -def runtests(*test_args): - if not settings.configured: - settings.configure(**DEFAULT_SETTINGS) - - django.setup() - - parent = os.path.dirname(os.path.abspath(__file__)) - sys.path.insert(0, parent) - - try: - from django.test.runner import DiscoverRunner - runner_class = DiscoverRunner - if not test_args: - test_args = ["oidc_provider.tests"] - except ImportError: - from django.test.simple import DjangoTestSuiteRunner - runner_class = DjangoTestSuiteRunner - if not test_args: - test_args = ["tests"] - - failures = runner_class(verbosity=1, interactive=True, failfast=False).run_tests(test_args) - sys.exit(failures) - - -if __name__ == "__main__": - runtests(*sys.argv[1:]) diff --git a/tox.ini b/tox.ini index bb1ed84..cf70af8 100644 --- a/tox.ini +++ b/tox.ini @@ -1,33 +1,35 @@ [tox] - envlist= - clean, py27-django{17,18,19,110,111}, py34-django{17,18,19,110,111,20}, py35-django{18,19,110,111,20}, py36-django{18,19,110,111,20}, - [testenv] - +changedir= + oidc_provider/tests/cases deps = + mock + psycopg2 + pytest + 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 django111: django>=1.11,<1.12 django20: django>=2.0,<2.1 - coverage - mock - commands = - coverage run setup.py test + pytest --flake8 --cov=oidc_provider {posargs} -[testenv:clean] - -commands= - coverage erase - -[testenv:stats] - -commands= - coverage report -m +[pytest] +DJANGO_SETTINGS_MODULE = oidc_provider.tests.settings +python_files = test_*.py +flake8-max-line-length = 99 +flake8-ignore = + .git ALL + __pycache__ ALL + .ropeproject ALL + */migrations ALL + manage.py ALL From c0fbad2cfd42ea84dbef792464dee1ca143f5b07 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Thu, 22 Mar 2018 12:53:23 -0300 Subject: [PATCH 30/71] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f126d84..4ba206d 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ [![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) -[![Documentation Status](https://readthedocs.org/projects/django-oidc-provider/badge/?version=v0.4.x)](http://django-oidc-provider.readthedocs.io/en/v0.4.x/?badge=v0.4.x) -[![Travis](https://travis-ci.org/juanifioren/django-oidc-provider.svg?branch=v0.4.x)](https://travis-ci.org/juanifioren/django-oidc-provider) +[![Documentation Status](https://readthedocs.org/projects/django-oidc-provider/badge/?version=master)](http://django-oidc-provider.readthedocs.io/en/master/?badge=master) +[![Travis](https://travis-ci.org/juanifioren/django-oidc-provider.svg?branch=master)](https://travis-ci.org/juanifioren/django-oidc-provider) ## About OpenID From d8309087594e1e0a764fed370e56f31476f4b130 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Thu, 22 Mar 2018 13:50:54 -0300 Subject: [PATCH 31/71] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4ba206d..97cf0bd 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![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) -[![Documentation Status](https://readthedocs.org/projects/django-oidc-provider/badge/?version=master)](http://django-oidc-provider.readthedocs.io/en/master/?badge=master) +[![Documentation Status](https://readthedocs.org/projects/django-oidc-provider/badge/?version=master)](http://django-oidc-provider.readthedocs.io/) [![Travis](https://travis-ci.org/juanifioren/django-oidc-provider.svg?branch=master)](https://travis-ci.org/juanifioren/django-oidc-provider) ## About OpenID From 5b57605daa49ea5e0cd5ddf10c351ac162354e9a Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Thu, 22 Mar 2018 16:48:54 -0300 Subject: [PATCH 32/71] Fix tox for checking PEP8 all files. --- tox.ini | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index 4e65c94..f0d7fc4 100644 --- a/tox.ini +++ b/tox.ini @@ -7,7 +7,7 @@ envlist= [testenv] changedir= - oidc_provider/tests/cases + oidc_provider deps = mock psycopg2 @@ -33,5 +33,4 @@ flake8-ignore = .git ALL __pycache__ ALL .ropeproject ALL - */migrations ALL - manage.py ALL + migrations/* ALL From 2a34a93da724b42db2b84675073b022c154c6dc1 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Thu, 22 Mar 2018 16:52:08 -0300 Subject: [PATCH 33/71] Fix contribute docs. --- docs/sections/contribute.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/sections/contribute.rst b/docs/sections/contribute.rst index f87b59d..068783a 100644 --- a/docs/sections/contribute.rst +++ b/docs/sections/contribute.rst @@ -13,16 +13,16 @@ We love contributions, so please feel free to fix bugs, improve things, provide Running Tests ============= -Use `tox `_ for running tests in each of the environments, also to run coverage among:: +Use `tox `_ for running tests in each of the environments, also to run coverage and flake8 among:: # Run all tests. $ tox - # Run with Python 2.7 and Django 1.9. - $ tox -e py27-django19 + # Run with Python 3.5 and Django 2.0. + $ tox -e py35-django20 - # Run single test file. - $ python runtests.py oidc_provider.tests.test_authorize_endpoint + # Run single test file on specific environment. + $ tox -e py35-django20 tests/cases/test_authorize_endpoint.py We also use `travis `_ to automatically test every commit to the project, From 7ec3a763d6893a377b71cf7e3a18e138251b46e8 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Thu, 22 Mar 2018 17:36:20 -0300 Subject: [PATCH 34/71] PEP8 models. --- oidc_provider/models.py | 36 +++++++++++++++++++++++------------- tox.ini | 2 +- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/oidc_provider/models.py b/oidc_provider/models.py index 9f78c0d..00287c1 100644 --- a/oidc_provider/models.py +++ b/oidc_provider/models.py @@ -33,17 +33,20 @@ JWT_ALGS = [ class Client(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_clients_set') + 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_clients_set') client_type = models.CharField( max_length=30, choices=CLIENT_TYPE_CHOICES, default='confidential', verbose_name=_(u'Client Type'), - help_text=_(u'Confidential clients are capable of maintaining the confidentiality of their credentials. ' - u'Public clients are incapable.')) + help_text=_(u'Confidential clients are capable of maintaining the confidentiality' + u' of their credentials. Public clients are incapable.')) client_id = models.CharField(max_length=255, unique=True, verbose_name=_(u'Client ID')) client_secret = models.CharField(max_length=255, blank=True, verbose_name=_(u'Client SECRET')) - response_type = models.CharField(max_length=30, choices=RESPONSE_TYPE_CHOICES, verbose_name=_(u'Response Type')) + response_type = models.CharField( + max_length=30, choices=RESPONSE_TYPE_CHOICES, verbose_name=_(u'Response Type')) jwt_alg = models.CharField( max_length=10, choices=JWT_ALGS, @@ -51,27 +54,31 @@ class Client(models.Model): verbose_name=_(u'JWT Algorithm'), help_text=_(u'Algorithm used to encode ID Tokens.')) date_created = models.DateField(auto_now_add=True, verbose_name=_(u'Date Created')) - website_url = models.CharField(max_length=255, blank=True, default='', verbose_name=_(u'Website URL')) + website_url = models.CharField( + max_length=255, blank=True, default='', verbose_name=_(u'Website URL')) terms_url = models.CharField( max_length=255, blank=True, default='', verbose_name=_(u'Terms URL'), help_text=_(u'External reference to the privacy policy of the client.')) - contact_email = models.CharField(max_length=255, blank=True, default='', verbose_name=_(u'Contact Email')) - logo = models.FileField(blank=True, default='', upload_to='oidc_provider/clients', verbose_name=_(u'Logo Image')) + contact_email = models.CharField( + max_length=255, blank=True, default='', verbose_name=_(u'Contact Email')) + logo = models.FileField( + blank=True, default='', upload_to='oidc_provider/clients', verbose_name=_(u'Logo Image')) reuse_consent = models.BooleanField( default=True, verbose_name=_('Reuse Consent?'), - help_text=_('If enabled, the Server will save the user consent given to a specific client, so that' - ' user won\'t be prompted for the same authorization multiple times.')) + help_text=_('If enabled, server will save the user consent given to a specific client, ' + 'so that user won\'t be prompted for the same authorization multiple times.')) require_consent = models.BooleanField( default=True, verbose_name=_('Require Consent?'), help_text=_('If disabled, the Server will NEVER ask the user for consent.')) _redirect_uris = models.TextField( - default='', verbose_name=_(u'Redirect URIs'), help_text=_(u'Enter each URI on a new line.')) + default='', verbose_name=_(u'Redirect URIs'), + help_text=_(u'Enter each URI on a new line.')) @property def redirect_uris(self): @@ -112,7 +119,8 @@ class Client(models.Model): class BaseCodeTokenModel(models.Model): - user = models.ForeignKey(settings.AUTH_USER_MODEL, verbose_name=_(u'User'), on_delete=models.CASCADE) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, verbose_name=_(u'User'), on_delete=models.CASCADE) client = models.ForeignKey(Client, verbose_name=_(u'Client'), on_delete=models.CASCADE) expires_at = models.DateTimeField(verbose_name=_(u'Expiration Date')) _scope = models.TextField(default='', verbose_name=_(u'Scopes')) @@ -144,7 +152,8 @@ class Code(BaseCodeTokenModel): nonce = models.CharField(max_length=255, blank=True, default='', verbose_name=_(u'Nonce')) is_authentication = models.BooleanField(default=False, verbose_name=_(u'Is Authentication?')) code_challenge = models.CharField(max_length=255, null=True, verbose_name=_(u'Code Challenge')) - code_challenge_method = models.CharField(max_length=255, null=True, verbose_name=_(u'Code Challenge Method')) + code_challenge_method = models.CharField( + max_length=255, null=True, verbose_name=_(u'Code Challenge Method')) class Meta: verbose_name = _(u'Authorization Code') @@ -192,7 +201,8 @@ class UserConsent(BaseCodeTokenModel): class RSAKey(models.Model): - key = models.TextField(verbose_name=_(u'Key'), help_text=_(u'Paste your private RSA Key here.')) + key = models.TextField( + verbose_name=_(u'Key'), help_text=_(u'Paste your private RSA Key here.')) class Meta: verbose_name = _(u'RSA Key') diff --git a/tox.ini b/tox.ini index f0d7fc4..db00837 100644 --- a/tox.ini +++ b/tox.ini @@ -28,7 +28,7 @@ commands = [pytest] DJANGO_SETTINGS_MODULE = oidc_provider.tests.settings python_files = test_*.py -flake8-max-line-length = 99 +flake8-max-line-length = 100 flake8-ignore = .git ALL __pycache__ ALL From 748ac231ca190c0e27e3b3b09bc3250b7003b934 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Thu, 22 Mar 2018 17:53:31 -0300 Subject: [PATCH 35/71] PEP8 errors and urls. --- oidc_provider/lib/errors.py | 3 ++- oidc_provider/urls.py | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/oidc_provider/lib/errors.py b/oidc_provider/lib/errors.py index 8533a75..5cd07d1 100644 --- a/oidc_provider/lib/errors.py +++ b/oidc_provider/lib/errors.py @@ -7,7 +7,8 @@ except ImportError: class RedirectUriError(Exception): error = 'Redirect URI Error' - description = 'The request fails due to a missing, invalid, or mismatching redirection URI (redirect_uri).' + description = 'The request fails due to a missing, invalid, or mismatching' \ + ' redirection URI (redirect_uri).' class ClientIdError(Exception): diff --git a/oidc_provider/urls.py b/oidc_provider/urls.py index 93033f3..0ad05b0 100644 --- a/oidc_provider/urls.py +++ b/oidc_provider/urls.py @@ -15,12 +15,13 @@ urlpatterns = [ url(r'^token/?$', csrf_exempt(views.TokenView.as_view()), name='token'), url(r'^userinfo/?$', csrf_exempt(views.userinfo), name='userinfo'), 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'^\.well-known/openid-configuration/?$', views.ProviderInfoView.as_view(), + name='provider-info'), url(r'^jwks/?$', views.JwksView.as_view(), name='jwks'), ] if settings.get('OIDC_SESSION_MANAGEMENT_ENABLE'): urlpatterns += [ - url(r'^check-session-iframe/?$', views.CheckSessionIframeView.as_view(), name='check-session-iframe'), + url(r'^check-session-iframe/?$', views.CheckSessionIframeView.as_view(), + name='check-session-iframe'), ] From 9dbdac65740aaa0ee894d58d8b63b258d61acdeb Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Fri, 23 Mar 2018 15:46:12 -0300 Subject: [PATCH 36/71] Fix PEP8. --- oidc_provider/lib/claims.py | 40 +++++++++++++++---- oidc_provider/lib/endpoints/token.py | 5 ++- oidc_provider/lib/errors.py | 3 +- oidc_provider/lib/utils/common.py | 20 ++++++---- oidc_provider/tests/app/urls.py | 8 ++-- oidc_provider/tests/app/utils.py | 8 ++-- .../tests/cases/test_end_session_endpoint.py | 2 +- .../tests/cases/test_userinfo_endpoint.py | 3 +- oidc_provider/views.py | 25 ++++++++---- 9 files changed, 77 insertions(+), 37 deletions(-) diff --git a/oidc_provider/lib/claims.py b/oidc_provider/lib/claims.py index d4af2ad..28c4602 100644 --- a/oidc_provider/lib/claims.py +++ b/oidc_provider/lib/claims.py @@ -6,11 +6,32 @@ from oidc_provider import settings STANDARD_CLAIMS = { - 'name': '', 'given_name': '', 'family_name': '', 'middle_name': '', 'nickname': '', - 'preferred_username': '', 'profile': '', 'picture': '', 'website': '', 'gender': '', - 'birthdate': '', 'zoneinfo': '', 'locale': '', 'updated_at': '', 'email': '', 'email_verified': '', - 'phone_number': '', 'phone_number_verified': '', 'address': { - 'formatted': '', 'street_address': '', 'locality': '', 'region': '', 'postal_code': '', 'country': '', }, + 'name': '', + 'given_name': '', + 'family_name': '', + 'middle_name': '', + 'nickname': '', + 'preferred_username': '', + 'profile': '', + 'picture': '', + 'website': '', + 'gender': '', + 'birthdate': '', + 'zoneinfo': '', + 'locale': '', + 'updated_at': '', + 'email': '', + 'email_verified': '', + 'phone_number': '', + 'phone_number_verified': '', + 'address': { + 'formatted': '', + 'street_address': '', + 'locality': '', + 'region': '', + 'postal_code': '', + 'country': '', + }, } @@ -99,14 +120,17 @@ class StandardScopeClaims(ScopeClaims): info_profile = ( _(u'Basic profile'), - _(u'Access to your basic information. Includes names, gender, birthdate and other information.'), + _(u'Access to your basic information. Includes names, gender, birthdate' + 'and other information.'), ) def scope_profile(self): dic = { 'name': self.userinfo.get('name'), - 'given_name': self.userinfo.get('given_name') or getattr(self.user, 'first_name', None), - 'family_name': self.userinfo.get('family_name') or getattr(self.user, 'last_name', None), + 'given_name': (self.userinfo.get('given_name') or + getattr(self.user, 'first_name', None)), + 'family_name': (self.userinfo.get('family_name') or + getattr(self.user, 'last_name', None)), 'middle_name': self.userinfo.get('middle_name'), 'nickname': self.userinfo.get('nickname') or getattr(self.user, 'username', None), 'preferred_username': self.userinfo.get('preferred_username'), diff --git a/oidc_provider/lib/endpoints/token.py b/oidc_provider/lib/endpoints/token.py index 5a6b0af..f3afebd 100644 --- a/oidc_provider/lib/endpoints/token.py +++ b/oidc_provider/lib/endpoints/token.py @@ -64,7 +64,7 @@ class TokenEndpoint(object): try: user_pass = b64decode(b64_user_pass).decode('utf-8').split(':') client_id, client_secret = tuple(user_pass) - except: + except Exception: client_id = client_secret = '' else: client_id = self.request.POST.get('client_id', '') @@ -138,7 +138,8 @@ class TokenEndpoint(object): client=self.client) except Token.DoesNotExist: - logger.debug('[Token] Refresh token does not exist: %s', self.params['refresh_token']) + logger.debug( + '[Token] Refresh token does not exist: %s', self.params['refresh_token']) raise TokenError('invalid_grant') else: diff --git a/oidc_provider/lib/errors.py b/oidc_provider/lib/errors.py index 5cd07d1..22d8e9a 100644 --- a/oidc_provider/lib/errors.py +++ b/oidc_provider/lib/errors.py @@ -23,8 +23,7 @@ class UserAuthError(Exception): the Resource Owners credentials are not valid. """ error = 'access_denied' - description = 'The resource owner or authorization server denied ' \ - 'the request' + description = 'The resource owner or authorization server denied the request.' def create_dict(self): return { diff --git a/oidc_provider/lib/utils/common.py b/oidc_provider/lib/utils/common.py index 26b68af..870d3ee 100644 --- a/oidc_provider/lib/utils/common.py +++ b/oidc_provider/lib/utils/common.py @@ -1,16 +1,16 @@ from hashlib import sha224 import django +from django.http import HttpResponse + +from oidc_provider import settings + if django.VERSION >= (1, 11): from django.urls import reverse else: from django.core.urlresolvers import reverse -from django.http import HttpResponse - -from oidc_provider import settings - def redirect(uri): """ @@ -77,17 +77,20 @@ def default_after_userlogin_hook(request, user, client): def default_after_end_session_hook( - request, id_token=None, post_logout_redirect_uri=None, state=None, client=None, next_page=None): + request, id_token=None, post_logout_redirect_uri=None, + state=None, client=None, next_page=None): """ Default function for setting OIDC_AFTER_END_SESSION_HOOK. :param request: Django request object :type request: django.http.HttpRequest - :param id_token: token passed by `id_token_hint` url query param - do NOT trust this param or validate token + :param id_token: token passed by `id_token_hint` url query param. + Do NOT trust this param or validate token :type id_token: str - :param post_logout_redirect_uri: redirect url from url query param - do NOT trust this param + :param post_logout_redirect_uri: redirect url from url query param. + Do NOT trust this param :type post_logout_redirect_uri: str :param state: state param from url query params @@ -124,5 +127,6 @@ def get_browser_state_or_default(request): """ Determine value to use as session state. """ - key = request.session.session_key or settings.get('OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY') + key = (request.session.session_key or + settings.get('OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY')) return sha224(key.encode('utf-8')).hexdigest() diff --git a/oidc_provider/tests/app/urls.py b/oidc_provider/tests/app/urls.py index d09fad1..e50bdfe 100644 --- a/oidc_provider/tests/app/urls.py +++ b/oidc_provider/tests/app/urls.py @@ -9,10 +9,10 @@ from django.views.generic import TemplateView urlpatterns = [ url(r'^$', TemplateView.as_view(template_name='home.html'), name='home'), - url(r'^accounts/login/$', auth_views.login, {'template_name': 'accounts/login.html'}, name='login'), - url(r'^accounts/logout/$', auth_views.logout, {'template_name': 'accounts/logout.html'}, name='logout'), - + url(r'^accounts/login/$', + auth_views.login, {'template_name': 'accounts/login.html'}, name='login'), + url(r'^accounts/logout/$', + auth_views.logout, {'template_name': 'accounts/logout.html'}, name='logout'), url(r'^openid/', include('oidc_provider.urls', namespace='oidc_provider')), - url(r'^admin/', admin.site.urls), ] diff --git a/oidc_provider/tests/app/utils.py b/oidc_provider/tests/app/utils.py index 4f824ec..c885e3c 100644 --- a/oidc_provider/tests/app/utils.py +++ b/oidc_provider/tests/app/utils.py @@ -15,7 +15,8 @@ from oidc_provider.models import ( FAKE_NONCE = 'cb584e44c43ed6bd0bc2d9c7e242837d' -FAKE_RANDOM_STRING = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(32)) +FAKE_RANDOM_STRING = ''.join( + random.choice(string.ascii_uppercase + string.digits) for _ in range(32)) FAKE_CODE_CHALLENGE = 'YlYXEqXuRm-Xgi2BOUiK50JW1KsGTX6F1TDnZSC8VTg' FAKE_CODE_VERIFIER = 'SmxGa0XueyNh5bDgTcSrqzAh2_FmXEqU8kDT6CuXicw' @@ -82,7 +83,7 @@ def is_code_valid(url, user, client): code = params['code'][0] code = Code.objects.get(code=code) is_code_ok = (code.client == client) and (code.user == user) - except: + except Exception: is_code_ok = False return is_code_ok @@ -118,7 +119,8 @@ def fake_idtoken_processing_hook(id_token, user): def fake_idtoken_processing_hook2(id_token, user): """ - Fake function for inserting some keys into token. Testing OIDC_IDTOKEN_PROCESSING_HOOK - tuple or list as param + 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 diff --git a/oidc_provider/tests/cases/test_end_session_endpoint.py b/oidc_provider/tests/cases/test_end_session_endpoint.py index 6125d19..50958d5 100644 --- a/oidc_provider/tests/cases/test_end_session_endpoint.py +++ b/oidc_provider/tests/cases/test_end_session_endpoint.py @@ -74,4 +74,4 @@ class EndSessionTestCase(TestCase): self.assertTrue(hook_function.called, 'OIDC_AFTER_END_SESSION_HOOK should be called') self.assertTrue( hook_function.call_count == 1, - 'OIDC_AFTER_END_SESSION_HOOK should be called once but was {}'.format(hook_function.call_count)) + 'OIDC_AFTER_END_SESSION_HOOK should be called once') diff --git a/oidc_provider/tests/cases/test_userinfo_endpoint.py b/oidc_provider/tests/cases/test_userinfo_endpoint.py index 1c41cc0..4be59a8 100644 --- a/oidc_provider/tests/cases/test_userinfo_endpoint.py +++ b/oidc_provider/tests/cases/test_userinfo_endpoint.py @@ -148,4 +148,5 @@ class UserInfoTestCase(TestCase): response_dic = json.loads(response.content.decode('utf-8')) self.assertIn('address', response_dic, msg='"address" claim should be in response.') - self.assertIn('country', response_dic['address'], msg='"country" claim should be in response.') + self.assertIn( + 'country', response_dic['address'], msg='"country" claim should be in response.') diff --git a/oidc_provider/views.py b/oidc_provider/views.py index c82b428..7a19bcc 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -73,7 +73,9 @@ class AuthorizeView(View): if 'login' in authorize.params['prompt']: if 'none' in authorize.params['prompt']: - raise AuthorizeError(authorize.params['redirect_uri'], 'login_required', authorize.grant_type) + raise AuthorizeError( + authorize.params['redirect_uri'], 'login_required', + authorize.grant_type) else: django_user_logout(request) next_page = self.strip_prompt_login(request.get_full_path()) @@ -83,13 +85,16 @@ class AuthorizeView(View): # TODO: see how we can support multiple accounts for the end-user. if 'none' in authorize.params['prompt']: raise AuthorizeError( - authorize.params['redirect_uri'], 'account_selection_required', authorize.grant_type) + authorize.params['redirect_uri'], 'account_selection_required', + authorize.grant_type) else: django_user_logout(request) - return redirect_to_login(request.get_full_path(), settings.get('OIDC_LOGIN_URL')) + return redirect_to_login( + request.get_full_path(), settings.get('OIDC_LOGIN_URL')) if {'none', 'consent'}.issubset(authorize.params['prompt']): - raise AuthorizeError(authorize.params['redirect_uri'], 'consent_required', authorize.grant_type) + raise AuthorizeError( + authorize.params['redirect_uri'], 'consent_required', authorize.grant_type) implicit_flow_resp_types = {'id_token', 'id_token token'} allow_skipping_consent = ( @@ -109,7 +114,8 @@ class AuthorizeView(View): return redirect(authorize.create_response_uri()) if 'none' in authorize.params['prompt']: - raise AuthorizeError(authorize.params['redirect_uri'], 'consent_required', authorize.grant_type) + raise AuthorizeError( + authorize.params['redirect_uri'], 'consent_required', authorize.grant_type) # Generate hidden inputs for the form. context = { @@ -132,7 +138,8 @@ class AuthorizeView(View): return render(request, OIDC_TEMPLATES['authorize'], context) else: if 'none' in authorize.params['prompt']: - raise AuthorizeError(authorize.params['redirect_uri'], 'login_required', authorize.grant_type) + raise AuthorizeError( + authorize.params['redirect_uri'], 'login_required', authorize.grant_type) if 'login' in authorize.params['prompt']: next_page = self.strip_prompt_login(request.get_full_path()) return redirect_to_login(next_page, settings.get('OIDC_LOGIN_URL')) @@ -162,14 +169,16 @@ class AuthorizeView(View): if not request.POST.get('allow'): signals.user_decline_consent.send( - self.__class__, user=request.user, client=authorize.client, scope=authorize.params['scope']) + self.__class__, user=request.user, + client=authorize.client, scope=authorize.params['scope']) raise AuthorizeError(authorize.params['redirect_uri'], 'access_denied', authorize.grant_type) signals.user_accept_consent.send( - self.__class__, user=request.user, client=authorize.client, scope=authorize.params['scope']) + self.__class__, user=request.user, client=authorize.client, + scope=authorize.params['scope']) # Save the user consent given to the client. authorize.set_client_user_consent() From 8545ada615826ac1551a0b229b78bc245b7c98a0 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Fri, 23 Mar 2018 16:53:23 -0300 Subject: [PATCH 37/71] Fix PEP8. --- .../tests/cases/test_token_endpoint.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/oidc_provider/tests/cases/test_token_endpoint.py b/oidc_provider/tests/cases/test_token_endpoint.py index 8295056..83ca2f1 100644 --- a/oidc_provider/tests/cases/test_token_endpoint.py +++ b/oidc_provider/tests/cases/test_token_endpoint.py @@ -254,7 +254,8 @@ class TokenTestCase(TestCase): ) response_dict = json.loads(response.content.decode('utf-8')) - id_token = JWS().verify_compact(response_dict['id_token'].encode('utf-8'), self._get_keys()) + id_token = JWS().verify_compact( + response_dict['id_token'].encode('utf-8'), self._get_keys()) token = Token.objects.get(user=self.user) self.assertEqual(response_dict['access_token'], token.access_token) @@ -468,13 +469,17 @@ class TokenTestCase(TestCase): for request in requests: response = TokenView.as_view()(request) - self.assertEqual(response.status_code, 405, msg=request.method + ' request does not return a 405 status.') + self.assertEqual( + response.status_code, 405, + msg=request.method + ' request does not return a 405 status.') request = self.factory.post(url) response = TokenView.as_view()(request) - self.assertEqual(response.status_code, 400, msg=request.method + ' request does not return a 400 status.') + self.assertEqual( + response.status_code, 400, + msg=request.method + ' request does not return a 400 status.') def test_client_authentication(self): """ @@ -594,7 +599,8 @@ class TokenTestCase(TestCase): JWS().verify_compact(response_dic['id_token'].encode('utf-8'), RSAKEYS) - @override_settings(OIDC_IDTOKEN_SUB_GENERATOR='oidc_provider.tests.app.utils.fake_sub_generator') + @override_settings( + OIDC_IDTOKEN_SUB_GENERATOR='oidc_provider.tests.app.utils.fake_sub_generator') def test_custom_sub_generator(self): """ Test custom function for setting OIDC_IDTOKEN_SUB_GENERATOR. @@ -610,7 +616,8 @@ class TokenTestCase(TestCase): self.assertEqual(id_token.get('sub'), self.user.email) - @override_settings(OIDC_IDTOKEN_PROCESSING_HOOK='oidc_provider.tests.app.utils.fake_idtoken_processing_hook') + @override_settings( + OIDC_IDTOKEN_PROCESSING_HOOK='oidc_provider.tests.app.utils.fake_idtoken_processing_hook') def test_additional_idtoken_processing_hook(self): """ Test custom function for setting OIDC_IDTOKEN_PROCESSING_HOOK. From b803f8917d3ccc834399c10b35f56c0742eb1f01 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Fri, 23 Mar 2018 17:06:44 -0300 Subject: [PATCH 38/71] Update example project. --- {example_project => example}/.gitignore | 0 {example_project => example}/Dockerfile | 2 +- {example_project => example}/README.md | 11 ++++------- {example_project/myapp => example/app}/__init__.py | 0 {example_project/myapp => example/app}/settings.py | 6 +++--- .../myapp => example/app}/templates/base.html | 0 .../myapp => example/app}/templates/home.html | 2 +- .../myapp => example/app}/templates/login.html | 0 .../app}/templates/oidc_provider/authorize.html | 0 .../app}/templates/oidc_provider/error.html | 0 {example_project/myapp => example/app}/urls.py | 0 {example_project/myapp => example/app}/wsgi.py | 4 +++- {example_project => example}/manage.py | 2 +- example/requirements.txt | 2 ++ example_project/requirements.txt | 2 -- 15 files changed, 15 insertions(+), 16 deletions(-) rename {example_project => example}/.gitignore (100%) rename {example_project => example}/Dockerfile (87%) rename {example_project => example}/README.md (75%) rename {example_project/myapp => example/app}/__init__.py (100%) rename {example_project/myapp => example/app}/settings.py (95%) rename {example_project/myapp => example/app}/templates/base.html (100%) rename {example_project/myapp => example/app}/templates/home.html (95%) rename {example_project/myapp => example/app}/templates/login.html (100%) rename {example_project/myapp => example/app}/templates/oidc_provider/authorize.html (100%) rename {example_project/myapp => example/app}/templates/oidc_provider/error.html (100%) rename {example_project/myapp => example/app}/urls.py (100%) rename {example_project/myapp => example/app}/wsgi.py (60%) rename {example_project => example}/manage.py (71%) create mode 100644 example/requirements.txt delete mode 100644 example_project/requirements.txt diff --git a/example_project/.gitignore b/example/.gitignore similarity index 100% rename from example_project/.gitignore rename to example/.gitignore diff --git a/example_project/Dockerfile b/example/Dockerfile similarity index 87% rename from example_project/Dockerfile rename to example/Dockerfile index a636e93..abe0b7e 100644 --- a/example_project/Dockerfile +++ b/example/Dockerfile @@ -1,4 +1,4 @@ -FROM python:2-onbuild +FROM python:3-onbuild RUN [ "python", "manage.py", "migrate" ] RUN [ "python", "manage.py", "creatersakey" ] diff --git a/example_project/README.md b/example/README.md similarity index 75% rename from example_project/README.md rename to example/README.md index 0a8dfef..c3f78c7 100644 --- a/example_project/README.md +++ b/example/README.md @@ -2,7 +2,7 @@ ![Example Project](https://s17.postimg.org/4jjj8lavj/Screen_Shot_2016_09_07_at_15_58_43.png) -Run your own OIDC provider in a second. This is a Django app with all the necessary things to work with `django-oidc-provider` package. +On this example you'll be running your own OIDC provider in a second. This is a Django app with all the necessary things to work with `django-oidc-provider` package. ## Setup & Running @@ -14,15 +14,12 @@ Run your own OIDC provider in a second. This is a Django app with all the necess Setup project environment with [virtualenv](https://virtualenv.pypa.io) and [pip](https://pip.pypa.io). ```bash -# For Python 2.7. -$ virtualenv project_env -# Or Python 3. -$ virtualenv -p /usr/bin/python3.4 project_env +$ virtualenv -p /usr/bin/python3 project_env $ source project_env/bin/activate $ git clone https://github.com/juanifioren/django-oidc-provider.git -$ cd django-oidc-provider/example_project +$ cd django-oidc-provider/example $ pip install -r requirements.txt ``` @@ -53,7 +50,7 @@ After you run `pip install -r requirements.txt`. # Remove pypi package. $ pip uninstall django-oidc-provider -# Go back and add the package again. +# Go back to django-oidc-provider/ folder and add the package on editable mode. $ cd .. $ pip install -e . ``` diff --git a/example_project/myapp/__init__.py b/example/app/__init__.py similarity index 100% rename from example_project/myapp/__init__.py rename to example/app/__init__.py diff --git a/example_project/myapp/settings.py b/example/app/settings.py similarity index 95% rename from example_project/myapp/settings.py rename to example/app/settings.py index 8d531d9..1c3e972 100644 --- a/example_project/myapp/settings.py +++ b/example/app/settings.py @@ -20,7 +20,7 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', - 'myapp', + 'app', 'oidc_provider', ] @@ -51,9 +51,9 @@ TEMPLATES = [ }, ] -ROOT_URLCONF = 'myapp.urls' +ROOT_URLCONF = 'app.urls' -WSGI_APPLICATION = 'myapp.wsgi.application' +WSGI_APPLICATION = 'app.wsgi.application' # Database diff --git a/example_project/myapp/templates/base.html b/example/app/templates/base.html similarity index 100% rename from example_project/myapp/templates/base.html rename to example/app/templates/base.html diff --git a/example_project/myapp/templates/home.html b/example/app/templates/home.html similarity index 95% rename from example_project/myapp/templates/home.html rename to example/app/templates/home.html index c2f2518..32e01ff 100644 --- a/example_project/myapp/templates/home.html +++ b/example/app/templates/home.html @@ -3,7 +3,7 @@ {% block content %} -
+

{% trans 'Welcome' %}{% if user.is_authenticated %} {{ user.username }}{% endif %}!

{% trans 'This is an example of an OpenID Connect 1.0 Provider. Built with the Django Framework and django-oidc-provider package.' %}

diff --git a/example_project/myapp/templates/login.html b/example/app/templates/login.html similarity index 100% rename from example_project/myapp/templates/login.html rename to example/app/templates/login.html diff --git a/example_project/myapp/templates/oidc_provider/authorize.html b/example/app/templates/oidc_provider/authorize.html similarity index 100% rename from example_project/myapp/templates/oidc_provider/authorize.html rename to example/app/templates/oidc_provider/authorize.html diff --git a/example_project/myapp/templates/oidc_provider/error.html b/example/app/templates/oidc_provider/error.html similarity index 100% rename from example_project/myapp/templates/oidc_provider/error.html rename to example/app/templates/oidc_provider/error.html diff --git a/example_project/myapp/urls.py b/example/app/urls.py similarity index 100% rename from example_project/myapp/urls.py rename to example/app/urls.py diff --git a/example_project/myapp/wsgi.py b/example/app/wsgi.py similarity index 60% rename from example_project/myapp/wsgi.py rename to example/app/wsgi.py index dd74e93..7c75d28 100644 --- a/example_project/myapp/wsgi.py +++ b/example/app/wsgi.py @@ -1,6 +1,8 @@ import os + from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myapp.settings') + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') application = get_wsgi_application() diff --git a/example_project/manage.py b/example/manage.py similarity index 71% rename from example_project/manage.py rename to example/manage.py index 7bf6f3d..7adfe49 100755 --- a/example_project/manage.py +++ b/example/manage.py @@ -3,7 +3,7 @@ import os import sys if __name__ == '__main__': - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myapp.settings') + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') from django.core.management import execute_from_command_line diff --git a/example/requirements.txt b/example/requirements.txt new file mode 100644 index 0000000..4f95392 --- /dev/null +++ b/example/requirements.txt @@ -0,0 +1,2 @@ +django +https://github.com/juanifioren/django-oidc-provider/archive/master.zip diff --git a/example_project/requirements.txt b/example_project/requirements.txt deleted file mode 100644 index 412a8f5..0000000 --- a/example_project/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -django==1.10 -https://github.com/juanifioren/django-oidc-provider/archive/v0.4.x.zip From 582587f337497643a661c088fb9cf794bb1bc2e1 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Tue, 27 Mar 2018 17:15:06 -0300 Subject: [PATCH 39/71] Fix PEP8. New migration. --- oidc_provider/lib/endpoints/authorize.py | 29 ++++++---- .../migrations/0024_auto_20180327_1959.py | 18 +++++++ .../tests/cases/test_authorize_endpoint.py | 54 ++++++++++++------- 3 files changed, 72 insertions(+), 29 deletions(-) create mode 100644 oidc_provider/migrations/0024_auto_20180327_1959.py diff --git a/oidc_provider/lib/endpoints/authorize.py b/oidc_provider/lib/endpoints/authorize.py index 3313bd6..e7072b1 100644 --- a/oidc_provider/lib/endpoints/authorize.py +++ b/oidc_provider/lib/endpoints/authorize.py @@ -49,7 +49,8 @@ class AuthorizeEndpoint(object): self.grant_type = 'authorization_code' elif self.params['response_type'] in ['id_token', 'id_token token', 'token']: self.grant_type = 'implicit' - elif self.params['response_type'] in ['code token', 'code id_token', 'code id_token token']: + elif self.params['response_type'] in [ + 'code token', 'code id_token', 'code id_token token']: self.grant_type = 'hybrid' else: self.grant_type = None @@ -76,7 +77,8 @@ class AuthorizeEndpoint(object): self.params['state'] = query_dict.get('state', '') self.params['nonce'] = query_dict.get('nonce', '') - self.params['prompt'] = self._allowed_prompt_params.intersection(set(query_dict.get('prompt', '').split())) + self.params['prompt'] = self._allowed_prompt_params.intersection( + set(query_dict.get('prompt', '').split())) self.params['code_challenge'] = query_dict.get('code_challenge', '') self.params['code_challenge_method'] = query_dict.get('code_challenge_method', '') @@ -100,10 +102,11 @@ class AuthorizeEndpoint(object): # Grant type validation. if not self.grant_type: logger.debug('[Authorize] Invalid response type: %s', self.params['response_type']) - raise AuthorizeError(self.params['redirect_uri'], 'unsupported_response_type', self.grant_type) + raise AuthorizeError( + self.params['redirect_uri'], 'unsupported_response_type', self.grant_type) - if (not self.is_authentication and - (self.grant_type == 'hybrid' or self.params['response_type'] in ['id_token', 'id_token token'])): + if (not self.is_authentication and (self.grant_type == 'hybrid' or + self.params['response_type'] in ['id_token', 'id_token token'])): logger.debug('[Authorize] Missing openid scope.') raise AuthorizeError(self.params['redirect_uri'], 'invalid_scope', self.grant_type) @@ -118,7 +121,8 @@ class AuthorizeEndpoint(object): # PKCE validation of the transformation method. if self.params['code_challenge']: if not (self.params['code_challenge_method'] in ['plain', 'S256']): - raise AuthorizeError(self.params['redirect_uri'], 'invalid_request', self.grant_type) + raise AuthorizeError( + self.params['redirect_uri'], 'invalid_request', self.grant_type) def create_response_uri(self): uri = urlsplit(self.params['redirect_uri']) @@ -147,7 +151,8 @@ class AuthorizeEndpoint(object): scope=self.params['scope']) # Check if response_type must include access_token in the response. - if self.params['response_type'] in ['id_token token', 'token', 'code token', 'code id_token token']: + if (self.params['response_type'] in + ['id_token token', 'token', 'code token', 'code id_token token']): query_fragment['access_token'] = token.access_token # We don't need id_token if it's an OAuth2 request. @@ -188,7 +193,8 @@ class AuthorizeEndpoint(object): if settings.get('OIDC_SESSION_MANAGEMENT_ENABLE'): # Generate client origin URI from the redirect_uri param. redirect_uri_parsed = urlsplit(self.params['redirect_uri']) - client_origin = '{0}://{1}'.format(redirect_uri_parsed.scheme, redirect_uri_parsed.netloc) + client_origin = '{0}://{1}'.format( + redirect_uri_parsed.scheme, redirect_uri_parsed.netloc) # Create random salt. salt = md5(uuid4().hex.encode()).hexdigest() @@ -213,7 +219,8 @@ class AuthorizeEndpoint(object): raise AuthorizeError(self.params['redirect_uri'], 'server_error', self.grant_type) uri = uri._replace( - query=urlencode(query_params, doseq=True), fragment=uri.fragment + urlencode(query_fragment, doseq=True)) + query=urlencode(query_params, doseq=True), + fragment=uri.fragment + urlencode(query_fragment, doseq=True)) return urlunsplit(uri) @@ -266,8 +273,8 @@ class AuthorizeEndpoint(object): """ scopes = StandardScopeClaims.get_scopes_info(self.params['scope']) if settings.get('OIDC_EXTRA_SCOPE_CLAIMS'): - scopes_extra = settings.get('OIDC_EXTRA_SCOPE_CLAIMS', import_str=True).get_scopes_info( - self.params['scope']) + scopes_extra = settings.get( + 'OIDC_EXTRA_SCOPE_CLAIMS', import_str=True).get_scopes_info(self.params['scope']) for index_extra, scope_extra in enumerate(scopes_extra): for index, scope in enumerate(scopes[:]): if scope_extra['scope'] == scope['scope']: diff --git a/oidc_provider/migrations/0024_auto_20180327_1959.py b/oidc_provider/migrations/0024_auto_20180327_1959.py new file mode 100644 index 0000000..7171661 --- /dev/null +++ b/oidc_provider/migrations/0024_auto_20180327_1959.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.3 on 2018-03-27 19:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oidc_provider', '0023_client_owner'), + ] + + operations = [ + migrations.AlterField( + model_name='client', + name='reuse_consent', + field=models.BooleanField(default=True, help_text="If enabled, server will save the user consent given to a specific client, so that user won't be prompted for the same authorization multiple times.", verbose_name='Reuse Consent?'), + ), + ] diff --git a/oidc_provider/tests/cases/test_authorize_endpoint.py b/oidc_provider/tests/cases/test_authorize_endpoint.py index 55e333e..d589274 100644 --- a/oidc_provider/tests/cases/test_authorize_endpoint.py +++ b/oidc_provider/tests/cases/test_authorize_endpoint.py @@ -70,7 +70,8 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): self.factory = RequestFactory() self.user = create_fake_user() self.client = create_fake_client(response_type='code') - self.client_with_no_consent = create_fake_client(response_type='code', require_consent=False) + self.client_with_no_consent = create_fake_client( + response_type='code', require_consent=False) self.client_public = create_fake_client(response_type='code', is_public=True) self.client_public_with_no_consent = create_fake_client( response_type='code', is_public=True, require_consent=False) @@ -199,7 +200,8 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): # Because user doesn't allow app, SHOULD exists an error parameter # in the query. self.assertIn('error=', response['Location'], msg='error param is missing in query.') - self.assertIn('access_denied', response['Location'], msg='"access_denied" code is missing in query.') + self.assertIn( + 'access_denied', response['Location'], msg='"access_denied" code is missing in query.') # Simulate user authorization. data['allow'] = 'Accept' # Will be the value of the button. @@ -280,10 +282,13 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): client=self.client) self.assertTrue(is_code_ok, msg='Code returned is invalid or missing') - self.assertEquals(set(params.keys()), {'state', 'code'}, msg='More than state or code appended as query params') + self.assertEquals( + set(params.keys()), {'state', 'code'}, + msg='More than state or code appended as query params') self.assertTrue( - response['Location'].startswith(self.client.default_redirect_uri), msg='Different redirect_uri returned') + response['Location'].startswith(self.client.default_redirect_uri), + msg='Different redirect_uri returned') def test_unknown_redirect_uris_are_rejected(self): """ @@ -299,7 +304,8 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): } response = self._auth_request('get', data) - self.assertIn(RedirectUriError.error, response.content.decode('utf-8'), msg='No redirect_uri error') + self.assertIn( + RedirectUriError.error, response.content.decode('utf-8'), msg='No redirect_uri error') def test_manipulated_redirect_uris_are_rejected(self): """ @@ -315,11 +321,13 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): } response = self._auth_request('get', data) - self.assertIn(RedirectUriError.error, response.content.decode('utf-8'), msg='No redirect_uri error') + self.assertIn( + RedirectUriError.error, response.content.decode('utf-8'), msg='No redirect_uri error') def test_public_client_auto_approval(self): """ - It's recommended not auto-approving requests for non-confidential clients using Authorization Code. + It's recommended not auto-approving requests for non-confidential + clients using Authorization Code. """ data = { 'client_id': self.client_public_with_no_consent.client_id, @@ -335,7 +343,8 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): def test_prompt_none_parameter(self): """ - Specifies whether the Authorization Server prompts the End-User for reauthentication and consent. + Specifies whether the Authorization Server prompts the End-User for + reauthentication and consent. See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest """ data = { @@ -354,13 +363,15 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): response = self._auth_request('get', data, is_user_authenticated=True) - # An error is returned if the Client does not have pre-configured consent for the requested Claims. + # An error is returned if the Client does not have pre-configured + # consent for the requested Claims. self.assertIn('consent_required', response['Location']) @patch('oidc_provider.views.django_user_logout') def test_prompt_login_parameter(self, logout_function): """ - Specifies whether the Authorization Server prompts the End-User for reauthentication and consent. + Specifies whether the Authorization Server prompts the End-User for + reauthentication and consent. See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest """ data = { @@ -393,7 +404,8 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): def test_prompt_login_none_parameter(self): """ - Specifies whether the Authorization Server prompts the End-User for reauthentication and consent. + Specifies whether the Authorization Server prompts the End-User for + reauthentication and consent. See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest """ data = { @@ -414,7 +426,8 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): @patch('oidc_provider.views.render') def test_prompt_consent_parameter(self, render_patched): """ - Specifies whether the Authorization Server prompts the End-User for reauthentication and consent. + Specifies whether the Authorization Server prompts the End-User for + reauthentication and consent. See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest """ data = { @@ -431,11 +444,13 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): response = self._auth_request('get', data, is_user_authenticated=True) render_patched.assert_called_once() - self.assertTrue(render_patched.call_args[0][1], settings.get('OIDC_TEMPLATES')['authorize']) + self.assertTrue( + render_patched.call_args[0][1], settings.get('OIDC_TEMPLATES')['authorize']) def test_prompt_consent_none_parameter(self): """ - Specifies whether the Authorization Server prompts the End-User for reauthentication and consent. + Specifies whether the Authorization Server prompts the End-User for + reauthentication and consent. See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest """ data = { @@ -633,7 +648,8 @@ class AuthorizationHybridFlowTestCase(TestCase, AuthorizeEndpointMixin): call_command('creatersakey') self.factory = RequestFactory() self.user = create_fake_user() - self.client_code_idtoken_token = create_fake_client(response_type='code id_token token', is_public=True) + self.client_code_idtoken_token = create_fake_client( + response_type='code id_token token', is_public=True) self.state = uuid.uuid4().hex self.nonce = uuid.uuid4().hex @@ -698,8 +714,9 @@ class TestCreateResponseURI(TestCase): @patch('oidc_provider.lib.endpoints.authorize.logger.exception') def test_create_response_uri_logs_to_error(self, log_exception, create_code): """ - A lot can go wrong when creating a response uri and this is caught with a general Exception error. The - information contained within this error should show up in the error log so production servers have something + A lot can go wrong when creating a response uri and this is caught + with a general Exception error. The information contained within this + error should show up in the error log so production servers have something to work with when things don't work as expected. """ exception = Exception("Something went wrong!") @@ -711,7 +728,8 @@ class TestCreateResponseURI(TestCase): with self.assertRaises(Exception): authorization_endpoint.create_response_uri() - log_exception.assert_called_once_with('[Authorize] Error when trying to create response uri: %s', exception) + log_exception.assert_called_once_with( + '[Authorize] Error when trying to create response uri: %s', exception) @override_settings(OIDC_SESSION_MANAGEMENT_ENABLE=True) def test_create_response_uri_generates_session_state_if_session_management_enabled(self): From bdb2fdb8f5731510799d88e5f3fb87f5a2c270c5 Mon Sep 17 00:00:00 2001 From: Morgan Aubert Date: Wed, 28 Mar 2018 10:34:01 -0400 Subject: [PATCH 40/71] Fixed infinite callback loop in check-session iframe This commit fixes the JS callback defined in the check-session iframe which can produce infinite callback loops if the received message doesn't come from the relying party. In that case another message is posted to the source of the message (which can be the OP itself) thus resulting in an infinite loop because "error" messages are continuously generated by the callback function. --- .../templates/oidc_provider/check_session_iframe.html | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/oidc_provider/templates/oidc_provider/check_session_iframe.html b/oidc_provider/templates/oidc_provider/check_session_iframe.html index 445fda2..e04d5ce 100644 --- a/oidc_provider/templates/oidc_provider/check_session_iframe.html +++ b/oidc_provider/templates/oidc_provider/check_session_iframe.html @@ -9,6 +9,10 @@ window.addEventListener("message", receiveMessage, false); function receiveMessage(e) { + if (!e.data || typeof e.data != 'string' || e.data == 'error') { + return; + } + var status; try { var clientId = e.data.split(' ')[0]; From b2128751dba75232a0965aefe037c7fb5398214e Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Wed, 28 Mar 2018 12:42:48 -0300 Subject: [PATCH 41/71] Update CHANGELOG.md --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b7899a..7f8fca5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ All notable changes to this project will be documented in this file. ##### Changed - Test suit now uses pytest. +##### Fixed +- Infinite callback loop in the check-session iframe. + ### [0.5.3] - 2018-03-09 ##### Fixed From b405bf7119ead41a50ad237ffece814092069051 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Wed, 28 Mar 2018 14:28:54 -0300 Subject: [PATCH 42/71] Update README.md --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 97cf0bd..0aafd1a 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,9 @@ Support for Python 3 and 2. Also latest versions of django. [Join us!](https://github.com/juanifioren/django-oidc-provider/graphs/contributors) we love contributions, so please feel free to fix bugs, improve things, provide documentation. You SHOULD follow this steps: -* Fork the project. +* Fork the project and create new branch from `develop`. * Make your feature addition or bug fix. * Add tests for it inside `oidc_provider/tests`. Then run all and ensure everything is OK (read docs for how to test in all envs). -* Send pull request to the `develop` branch. +* Create an issue and explain your changes. +* Create pull request for the issue to the `develop` branch. +* Wait for the collaborators to review it. From ff3d6ebe1ad7a0e55d2836d06e21576fe62f1194 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Thu, 5 Apr 2018 17:27:01 -0300 Subject: [PATCH 43/71] Move changelog into docs. --- CHANGELOG.md | 324 ----------------------- docs/index.rst | 2 + docs/sections/changelog.rst | 495 ++++++++++++++++++++++++++++++++++++ 3 files changed, 497 insertions(+), 324 deletions(-) delete mode 100644 CHANGELOG.md create mode 100644 docs/sections/changelog.rst diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 7f8fca5..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,324 +0,0 @@ -# CHANGELOG - -All notable changes to this project will be documented in this file. - -### Unreleased - -##### Added -- Add pep8 compliance and checker. - -##### Changed -- Test suit now uses pytest. - -##### Fixed -- Infinite callback loop in the check-session iframe. - -### [0.5.3] - 2018-03-09 - -##### Fixed -- Update project to support Django 2.0 - -### [0.5.2] - 2017-08-22 - -##### Fixed -- Fix infinite login loop if "prompt=login" (#198) -- Fix Django 2.0 deprecation warnings (#185) - -### [0.5.1] - 2017-07-11 - -##### Changed -- Documentation template changed to `Read The Docs`. - -##### Fixed -- `install_requires` has not longer pinned versions. -- Removed infinity loop during authorization stage when `prompt=login` has been send. -- Changed `prompt` handling as set of options instead of regular string. -- Redirect URI must match exactly with given in query parameter. -- Stored user consent are useful for public clients too. -- Fixed documentation for custom scopes handling. -- Scopes during refresh and code exchange are being taken from authorization request and not from query parameters. - -### [0.5.0] - 2017-05-18 - -##### Added -- Signals when user accept/decline the authorization page. -- `OIDC_AFTER_END_SESSION_HOOK` setting for additional business logic. -- Feature granttype password. -- require_consent and reuse_consent are added to Client model. - -##### Changed -- OIDC_SKIP_CONSENT_ALWAYS and OIDC_SKIP_CONSENT_ENABLE are removed from settings. - -##### Fixed -- Timestamps with unixtime (instead of django timezone). -- Field refresh_token cannot be primary key if null. -- `create_uri_exceptions` are now being logged at `Exception` level not `DEBUG`. - -### [0.4.4] - 2016-11-29 - -##### Fixed -- Bug in Session Management middleware when using Python 3. -- Translations handling. - -### [0.4.3] - 2016-11-02 - -##### Added -- Session Management 1.0 support. -- post_logout_redirect_uris into admin. - -##### Changed -- Package url names. -- Rename /logout/ url to /end-session/. - -##### Fixed -- Bug when trying authorize with response_type id_token without openid scope. - -### [0.4.2] - 2016-10-13 - -##### Added -- Support for client redirect URIs with query strings. - -##### Fixed -- Bug when generating secret_key value using admin. - -##### Changed -- Client is available to OIDC_EXTRA_SCOPE_CLAIMS implementations via `self.client`. -- The constructor signature for `ScopeClaims` has changed, it now is called with the `Token` as its single argument. - -### [0.4.1] - 2016-10-03 - -##### Changed -- Update pyjwkest to version 1.3.0. -- Use Cryptodome instead of Crypto lib. - -### [0.4.0] - 2016-09-12 - -##### Added -- Support for Hybrid Flow. -- New attributes for Clients: Website url, logo, contact email, terms url. -- Polish translations. -- Examples section in documentation. - -##### Fixed -- CORS in discovery and userinfo endpoint. -- Client type public bug when created using the admin. -- Missing OIDC_TOKEN_EXPIRE setting on implicit flow. - -### [0.3.7] - 2016-08-31 - -##### Added -- Support for Django 1.10. -- Initial translation files (ES, FR). -- Support for at_hash parameter. - -##### Fixed -- Empty address dict in userinfo response. - -### [0.3.6] - 2016-07-07 - -##### Changed -- OIDC_USERINFO setting. - -### [0.3.5] - 2016-06-21 - -##### Added -- Field date_given in UserConsent model. -- Verbose names to all model fields. -- Customize scopes names and descriptions on authorize template. - -##### Changed -- OIDC_EXTRA_SCOPE_CLAIMS setting. - -### [0.3.4] - 2016-06-10 - -##### Changed -- Make SITE_URL setting optional. - -##### Fixed -- Missing migration. - -### [0.3.3] - 2016-05-03 - -##### Fixed -- Important bug with PKCE and form submit in Auth Request. - -### [0.3.2] - 2016-04-26 - -##### Added -- Choose type of client on creation. -- Implement Proof Key for Code Exchange by OAuth Public Clients. -- Support for prompt parameter. -- Support for different client JWT tokens algorithm. - -##### Fixed -- Not auto-approve requests for non-confidential clients (publics). - -### [0.3.1] - 2016-03-09 - -##### Fixed -- response_type was not being validated (OpenID request). - -### [0.3.0] - 2016-02-23 - -##### Added -- Support OAuth2 requests. -- Decorator for protecting views with OAuth2. -- Setting OIDC_IDTOKEN_PROCESSING_HOOK. - -### [0.2.5] - 2016-02-03 - -##### Added -- Setting OIDC_SKIP_CONSENT_ALWAYS. - -##### Changed -- Removing OIDC_RSA_KEY_FOLDER setting. Moving RSA Keys to the database. -- Update pyjwkest to version 1.1.0. - -##### Fixed -- Nonce parameter missing on the decide form. -- Set Allow-Origin header to jwks endpoint. - -### [0.2.4] - 2016-01-20 - -##### Added -- Auto-generation of client ID and SECRET using the admin. -- Validate nonce parameter when using Implicit Flow. - -##### Fixed -- Fixed generating RSA key by ignoring value of OIDC_RSA_KEY_FOLDER. -- Make OIDC_AFTER_USERLOGIN_HOOK and OIDC_IDTOKEN_SUB_GENERATOR to be lazy imported by the location of the function. -- Problem with a function that generate urls for the /.well-known/openid-configuration/ endpoint. - -### [0.2.3] - 2016-01-06 - -##### Added -- Make user and client unique on UserConsent model. -- Support for URL's without end slash. - -##### Changed -- Upgrade pyjwkest to version 1.0.8. - -##### Fixed -- String format error in models. -- Redirect to non http urls fail (for Mobile Apps). - -### [0.2.1] - 2015-10-21 - -##### Added -- Refresh token flow. - -##### Changed -- Upgrade pyjwkest to version >= 1.0.6. - -##### Fixed -- Unicode error in Client model. -- Bug in creatersakey command (when using Python 3). -- Bug when updating pyjwkest version. - -### [0.2.0] - 2015-09-25 - -##### Changed -- UserInfo model was removed. Now you can add your own model using OIDC_USERINFO setting. - -##### Fixed -- ID token does NOT contain kid. - -### [0.1.2] - 2015-08-04 - -##### Added -- Add token_endpoint_auth_methods_supported to discovery. - -##### Fixed -- Missing commands folder in setup file. - -### [0.1.1] - 2015-07-31 - -##### Added -- Sending access_token as query string parameter in UserInfo Endpoint. -- Support HTTP Basic client authentication. - -##### Changed -- Use models setting instead of User. - -##### Fixed -- In python 2: "aud" and "nonce" parameters didn't appear in id_token. - -### [0.1.0] - 2015-07-17 - -##### Added -- Now id tokens are signed/encrypted with RS256. -- Command for easily generate random RSA key. -- Jwks uri to discovery endpoint. -- id_token_signing_alg_values_supported to discovery endpoint. - -##### Fixed -- Nonce support for both Code and Implicit flow. - -### [0.0.7] - 2015-07-06 - -##### Added -- Support for Python 3. -- Way of remember user consent and skipt it (OIDC_SKIP_CONSENT_ENABLE). -- Setting OIDC_SKIP_CONSENT_EXPIRE. - -##### Changed -- Now OIDC_EXTRA_SCOPE_CLAIMS must be a string, to be lazy imported. - -### [0.0.6] - 2015-06-16 - -##### Added -- Better naming for models in the admin. - -##### Changed -- Now tests run without the need of a project configured. - -##### Fixed -- Error when returning address_formatted claim. - -### [0.0.5] - 2015-05-09 - -##### Added -- Support for Django 1.8. - -##### Fixed -- Validation of scope in UserInfo endpoint. - -### [0.0.4] - 2015-04-22 - -##### Added -- Initial migrations. - -##### Fixed -- Important bug with id_token when using implicit flow. -- Validate Code expiration in Auth Code Flow. -- Validate Access Token expiration in UserInfo endpoint. - -### [0.0.3] - 2015-04-15 - -##### Added -- Normalize gender field in UserInfo. - -##### Changed -- Make address_formatted a property inside UserInfo. - -##### Fixed -- Important bug in claims response. - -### [0.0.2] - 2015-03-26 - -##### Added -- Setting OIDC_AFTER_USERLOGIN_HOOK. - -##### Fixed -- Tests failing because an incorrect tag in one template. - -### [0.0.1] - 2015-03-13 - -##### Added -- Provider Configuration Information endpoint. -- Setting OIDC_IDTOKEN_SUB_GENERATOR. - -##### Changed -- Now use setup in OIDC_EXTRA_SCOPE_CLAIMS setting. - -### [0.0.0] - 2015-02-26 diff --git a/docs/index.rst b/docs/index.rst index ae586a8..be652a5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -39,6 +39,8 @@ Contents: sections/signals sections/examples sections/contribute + sections/contribute + sections/changelog .. Indices and tables diff --git a/docs/sections/changelog.rst b/docs/sections/changelog.rst new file mode 100644 index 0000000..7265ab9 --- /dev/null +++ b/docs/sections/changelog.rst @@ -0,0 +1,495 @@ +.. _changelog: + +Changelog +######### + +All notable changes to this project will be documented in this file. + +Unreleased +========== + +**Added** + +* Add pep8 compliance and checker. + +**Changed** + +* Test suit now uses pytest. + +**Fixed** + +* Infinite callback loop in the check-session iframe. + +0.5.3 +===== + +*2018-03-09* + +**Fixed** + +* Update project to support Django 2.0 + +0.5.2 +===== + +*2017-08-22* + +**Fixed** + +* Fix infinite login loop if "prompt=login" (#198) +* Fix Django 2.0 deprecation warnings (#185) + +0.5.1 +===== + +*2017-07-11* + +**Changed** + +* Documentation template changed to `Read The Docs`. + +**Fixed** + +* `install_requires` has not longer pinned versions. +* Removed infinity loop during authorization stage when `prompt=login` has been send. +* Changed `prompt` handling as set of options instead of regular string. +* Redirect URI must match exactly with given in query parameter. +* Stored user consent are useful for public clients too. +* Fixed documentation for custom scopes handling. +* Scopes during refresh and code exchange are being taken from authorization request and not from query parameters. + +0.5.0 +===== + +*2017-05-18* + +**Added** + +* Signals when user accept/decline the authorization page. +* `OIDC_AFTER_END_SESSION_HOOK` setting for additional business logic. +* Feature granttype password. +* require_consent and reuse_consent are added to Client model. + +**Changed** + +* OIDC_SKIP_CONSENT_ALWAYS and OIDC_SKIP_CONSENT_ENABLE are removed from settings. + +**Fixed** + +* Timestamps with unixtime (instead of django timezone). +* Field refresh_token cannot be primary key if null. +* `create_uri_exceptions` are now being logged at `Exception` level not `DEBUG`. + +0.4.4 +===== + +*2016-11-29* + +**Fixed** + +* Bug in Session Management middleware when using Python 3. +* Translations handling. + +0.4.3 +===== + +*2016-11-02* + +**Added** + +* Session Management 1.0 support. +* post_logout_redirect_uris into admin. + +**Changed** + +* Package url names. +* Rename /logout/ url to /end-session/. + +**Fixed** + +* Bug when trying authorize with response_type id_token without openid scope. + +0.4.2 +===== + +*2016-10-13* + +**Added** + +* Support for client redirect URIs with query strings. + +**Fixed** + +* Bug when generating secret_key value using admin. + +**Changed** + +* Client is available to OIDC_EXTRA_SCOPE_CLAIMS implementations via `self.client`. +* The constructor signature for `ScopeClaims` has changed, it now is called with the `Token` as its single argument. + +0.4.1 +===== + +*2016-10-03* + +**Changed** + +* Update pyjwkest to version 1.3.0. +* Use Cryptodome instead of Crypto lib. + +0.4.0 +===== + +*2016-09-12* + +**Added** + +* Support for Hybrid Flow. +* New attributes for Clients: Website url, logo, contact email, terms url. +* Polish translations. +* Examples section in documentation. + +**Fixed** + +* CORS in discovery and userinfo endpoint. +* Client type public bug when created using the admin. +* Missing OIDC_TOKEN_EXPIRE setting on implicit flow. + +0.3.7 +===== + +*2016-08-31* + +**Added** + +* Support for Django 1.10. +* Initial translation files (ES, FR). +* Support for at_hash parameter. + +**Fixed** + +* Empty address dict in userinfo response. + +0.3.6 +===== + +*2016-07-07* + +**Changed** + +* OIDC_USERINFO setting. + +0.3.5 +===== + +*2016-06-21* + +**Added** + +* Field date_given in UserConsent model. +* Verbose names to all model fields. +* Customize scopes names and descriptions on authorize template. + +**Changed** + +* OIDC_EXTRA_SCOPE_CLAIMS setting. + +0.3.4 +===== + +*2016-06-10* + +**Changed** + +* Make SITE_URL setting optional. + +**Fixed** + +* Missing migration. + +0.3.3 +===== + +*2016-05-03* + +**Fixed** + +* Important bug with PKCE and form submit in Auth Request. + +0.3.2 +===== + +*2016-04-26* + +**Added** + +* Choose type of client on creation. +* Implement Proof Key for Code Exchange by OAuth Public Clients. +* Support for prompt parameter. +* Support for different client JWT tokens algorithm. + +**Fixed** + +* Not auto-approve requests for non-confidential clients (publics). + +0.3.1 +===== + +*2016-03-09* + +**Fixed** + +* response_type was not being validated (OpenID request). + +0.3.0 +===== + +*2016-02-23* + +**Added** + +* Support OAuth2 requests. +* Decorator for protecting views with OAuth2. +* Setting OIDC_IDTOKEN_PROCESSING_HOOK. + +0.2.5 +===== + +*2016-02-03* + +**Added** + +* Setting OIDC_SKIP_CONSENT_ALWAYS. + +**Changed** + +* Removing OIDC_RSA_KEY_FOLDER setting. Moving RSA Keys to the database. +* Update pyjwkest to version 1.1.0. + +**Fixed** + +* Nonce parameter missing on the decide form. +* Set Allow-Origin header to jwks endpoint. + +0.2.4 +===== + +*2016-01-20* + +**Added** + +* Auto-generation of client ID and SECRET using the admin. +* Validate nonce parameter when using Implicit Flow. + +**Fixed** + +* Fixed generating RSA key by ignoring value of OIDC_RSA_KEY_FOLDER. +* Make OIDC_AFTER_USERLOGIN_HOOK and OIDC_IDTOKEN_SUB_GENERATOR to be lazy imported by the location of the function. +* Problem with a function that generate urls for the /.well-known/openid-configuration/ endpoint. + +0.2.3 +===== + +*2016-01-06* + +**Added** + +* Make user and client unique on UserConsent model. +* Support for URL's without end slash. + +**Changed** + +* Upgrade pyjwkest to version 1.0.8. + +**Fixed** + +* String format error in models. +* Redirect to non http urls fail (for Mobile Apps). + +0.2.1 +===== + +*2015-10-21* + +**Added** + +* Refresh token flow. + +**Changed** + +* Upgrade pyjwkest to version >= 1.0.6. + +**Fixed** + +* Unicode error in Client model. +* Bug in creatersakey command (when using Python 3). +* Bug when updating pyjwkest version. + +0.2.0 +===== + +*2015-09-25* + +**Changed** + +* UserInfo model was removed. Now you can add your own model using OIDC_USERINFO setting. + +**Fixed** + +* ID token does NOT contain kid. + +0.1.2 +===== + +*2015-08-04* + +**Added** + +* Add token_endpoint_auth_methods_supported to discovery. + +**Fixed** + +* Missing commands folder in setup file. + +0.1.1 +===== + +*2015-07-31* + +**Added** + +* Sending access_token as query string parameter in UserInfo Endpoint. +* Support HTTP Basic client authentication. + +**Changed** + +* Use models setting instead of User. + +**Fixed** + +* In python 2: "aud" and "nonce" parameters didn't appear in id_token. + +0.1.0 +===== + +*2015-07-17* + +**Added** + +* Now id tokens are signed/encrypted with RS256. +* Command for easily generate random RSA key. +* Jwks uri to discovery endpoint. +* id_token_signing_alg_values_supported to discovery endpoint. + +**Fixed** + +* Nonce support for both Code and Implicit flow. + +0.0.7 +===== + +*2015-07-06* + +**Added** + +* Support for Python 3. +* Way of remember user consent and skipt it (OIDC_SKIP_CONSENT_ENABLE). +* Setting OIDC_SKIP_CONSENT_EXPIRE. + +**Changed** + +* Now OIDC_EXTRA_SCOPE_CLAIMS must be a string, to be lazy imported. + +0.0.6 +===== + +*2015-06-16* + +**Added** + +* Better naming for models in the admin. + +**Changed** + +* Now tests run without the need of a project configured. + +**Fixed** + +* Error when returning address_formatted claim. + +0.0.5 +===== + +*2015-05-09* + +**Added** + +* Support for Django 1.8. + +**Fixed** + +* Validation of scope in UserInfo endpoint. + +0.0.4 +===== + +*2015-04-22* + +**Added** + +* Initial migrations. + +**Fixed** + +* Important bug with id_token when using implicit flow. +* Validate Code expiration in Auth Code Flow. +* Validate Access Token expiration in UserInfo endpoint. + +0.0.3 +===== + +*2015-04-15* + +**Added** + +* Normalize gender field in UserInfo. + +**Changed** + +* Make address_formatted a property inside UserInfo. + +**Fixed** + +* Important bug in claims response. + +0.0.2 +===== + +*2015-03-26* + +**Added** + +* Setting OIDC_AFTER_USERLOGIN_HOOK. + +**Fixed** + +* Tests failing because an incorrect tag in one template. + +0.0.1 +===== + +*2015-03-13* + +**Added** + +* Provider Configuration Information endpoint. +* Setting OIDC_IDTOKEN_SUB_GENERATOR. + +**Changed** + +* Now use setup in OIDC_EXTRA_SCOPE_CLAIMS setting. + +0.0.0 +===== + +*2015-02-26* From dbed87aa78cd3ae2b1c1b37dd2b9fa9e72e7a45e Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Sun, 8 Apr 2018 17:43:24 -0300 Subject: [PATCH 44/71] Client credentials implementation. --- oidc_provider/admin.py | 2 +- oidc_provider/lib/endpoints/token.py | 80 ++++++++++++------- .../migrations/0025_client_credentials.py | 25 ++++++ oidc_provider/models.py | 55 ++++++++----- .../tests/cases/test_token_endpoint.py | 53 +++++++----- 5 files changed, 142 insertions(+), 73 deletions(-) create mode 100644 oidc_provider/migrations/0025_client_credentials.py diff --git a/oidc_provider/admin.py b/oidc_provider/admin.py index 71542b5..7718897 100644 --- a/oidc_provider/admin.py +++ b/oidc_provider/admin.py @@ -56,7 +56,7 @@ class ClientAdmin(admin.ModelAdmin): 'require_consent', 'reuse_consent'), }], [_(u'Credentials'), { - 'fields': ('client_id', 'client_secret'), + 'fields': ('client_id', 'client_secret', '_scope'), }], [_(u'Information'), { 'fields': ('contact_email', 'website_url', 'terms_url', 'logo', 'date_created'), diff --git a/oidc_provider/lib/endpoints/token.py b/oidc_provider/lib/endpoints/token.py index f3afebd..956ccaa 100644 --- a/oidc_provider/lib/endpoints/token.py +++ b/oidc_provider/lib/endpoints/token.py @@ -141,7 +141,10 @@ class TokenEndpoint(object): logger.debug( '[Token] Refresh token does not exist: %s', self.params['refresh_token']) raise TokenError('invalid_grant') - + elif self.params['grant_type'] == 'client_credentials': + if not self.client._scope: + logger.debug('[Token] Client using client credentials with empty scope') + raise TokenError('invalid_scope') else: logger.debug('[Token] Invalid grant type: %s', self.params['grant_type']) raise TokenError('unsupported_grant_type') @@ -153,34 +156,8 @@ class TokenEndpoint(object): return self.create_refresh_response_dic() elif self.params['grant_type'] == 'password': 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, - self.params['scope'].split(' ')) - - id_token_dic = create_id_token( - user=self.user, - aud=self.client.client_id, - nonce='self.code.nonce', - at_hash=token.at_hash, - request=self.request, - scope=token.scope, - ) - - token.id_token = id_token_dic - token.save() - - return { - 'access_token': token.access_token, - 'refresh_token': token.refresh_token, - 'expires_in': settings.get('OIDC_TOKEN_EXPIRE'), - 'token_type': 'bearer', - 'id_token': encode_id_token(id_token_dic, token.client), - } + elif self.params['grant_type'] == 'client_credentials': + return self.create_client_credentials_response_dic() def create_code_response_dic(self): # See https://tools.ietf.org/html/rfc6749#section-4.1 @@ -263,6 +240,51 @@ class TokenEndpoint(object): return 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, + self.params['scope'].split(' ')) + + id_token_dic = create_id_token( + user=self.user, + aud=self.client.client_id, + nonce='self.code.nonce', + at_hash=token.at_hash, + request=self.request, + scope=token.scope, + ) + + token.id_token = id_token_dic + token.save() + + return { + 'access_token': token.access_token, + 'refresh_token': token.refresh_token, + 'expires_in': settings.get('OIDC_TOKEN_EXPIRE'), + 'token_type': 'bearer', + 'id_token': encode_id_token(id_token_dic, token.client), + } + + def create_client_credentials_response_dic(self): + # See https://tools.ietf.org/html/rfc6749#section-4.4.3 + + token = create_token( + user=None, + client=self.client, + scope=self.client.scope) + + token.save() + + return { + 'access_token': token.access_token, + 'expires_in': settings.get('OIDC_TOKEN_EXPIRE'), + 'token_type': 'bearer', + 'scope': self.client._scope, + } + @classmethod def response(cls, dic, status=200): """ diff --git a/oidc_provider/migrations/0025_client_credentials.py b/oidc_provider/migrations/0025_client_credentials.py new file mode 100644 index 0000000..5ca3c4e --- /dev/null +++ b/oidc_provider/migrations/0025_client_credentials.py @@ -0,0 +1,25 @@ +# Generated by Django 2.0.3 on 2018-04-07 21:51 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('oidc_provider', '0024_auto_20180327_1959'), + ] + + operations = [ + migrations.AddField( + model_name='client', + name='_scope', + field=models.TextField(blank=True, default='', help_text='Specifies the authorized scope values for the client app.', verbose_name='Scopes'), + ), + migrations.AlterField( + model_name='token', + name='user', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'), + ), + ] diff --git a/oidc_provider/models.py b/oidc_provider/models.py index 00287c1..0ca6b12 100644 --- a/oidc_provider/models.py +++ b/oidc_provider/models.py @@ -75,32 +75,19 @@ class Client(models.Model): default=True, verbose_name=_('Require Consent?'), help_text=_('If disabled, the Server will NEVER ask the user for consent.')) - _redirect_uris = models.TextField( default='', verbose_name=_(u'Redirect URIs'), help_text=_(u'Enter each URI on a new line.')) - - @property - def redirect_uris(self): - return self._redirect_uris.splitlines() - - @redirect_uris.setter - def redirect_uris(self, value): - self._redirect_uris = '\n'.join(value) - _post_logout_redirect_uris = models.TextField( blank=True, default='', verbose_name=_(u'Post Logout Redirect URIs'), help_text=_(u'Enter each URI on a new line.')) - - @property - def post_logout_redirect_uris(self): - return self._post_logout_redirect_uris.splitlines() - - @post_logout_redirect_uris.setter - def post_logout_redirect_uris(self, value): - self._post_logout_redirect_uris = '\n'.join(value) + _scope = models.TextField( + blank=True, + default='', + verbose_name=_(u'Scopes'), + help_text=_('Specifies the authorized scope values for the client app.')) class Meta: verbose_name = _(u'Client') @@ -112,6 +99,30 @@ class Client(models.Model): def __unicode__(self): return self.__str__() + @property + def redirect_uris(self): + return self._redirect_uris.splitlines() + + @redirect_uris.setter + def redirect_uris(self, value): + self._redirect_uris = '\n'.join(value) + + @property + def post_logout_redirect_uris(self): + return self._post_logout_redirect_uris.splitlines() + + @post_logout_redirect_uris.setter + def post_logout_redirect_uris(self, value): + self._post_logout_redirect_uris = '\n'.join(value) + + @property + def scope(self): + return self._scope.split() + + @scope.setter + def scope(self, value): + self._scope = ' '.join(value) + @property def default_redirect_uri(self): return self.redirect_uris[0] if self.redirect_uris else '' @@ -125,6 +136,9 @@ class BaseCodeTokenModel(models.Model): expires_at = models.DateTimeField(verbose_name=_(u'Expiration Date')) _scope = models.TextField(default='', verbose_name=_(u'Scopes')) + class Meta: + abstract = True + @property def scope(self): return self._scope.split() @@ -142,9 +156,6 @@ class BaseCodeTokenModel(models.Model): def __unicode__(self): return self.__str__() - class Meta: - abstract = True - class Code(BaseCodeTokenModel): @@ -162,6 +173,8 @@ class Code(BaseCodeTokenModel): class Token(BaseCodeTokenModel): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, verbose_name=_(u'User'), on_delete=models.CASCADE) access_token = models.CharField(max_length=255, unique=True, verbose_name=_(u'Access Token')) refresh_token = models.CharField(max_length=255, unique=True, verbose_name=_(u'Refresh Token')) _id_token = models.TextField(verbose_name=_(u'ID Token')) diff --git a/oidc_provider/tests/cases/test_token_endpoint.py b/oidc_provider/tests/cases/test_token_endpoint.py index 83ca2f1..2ce8555 100644 --- a/oidc_provider/tests/cases/test_token_endpoint.py +++ b/oidc_provider/tests/cases/test_token_endpoint.py @@ -152,28 +152,6 @@ class TokenTestCase(TestCase): auth_header = {'HTTP_AUTHORIZATION': auth.decode('utf-8')} return auth_header - # Resource Owner Password Credentials Grant - # requirements to satisfy in all test_password_grant methods - # https://tools.ietf.org/html/rfc6749#section-4.3.2 - # - # grant_type - # REQUIRED. Value MUST be set to "password". - # username - # REQUIRED. The resource owner username. - # password - # REQUIRED. The resource owner password. - # scope - # OPTIONAL. The scope of the access request as described by - # Section 3.3. - # - # The authorization server MUST: - # o require client authentication for confidential clients or for any - # client that was issued client credentials (or with other - # authentication requirements), - # o authenticate the client if client authentication is included, and - # o validate the resource owner password credentials using its - # existing password validation algorithm. - def test_default_setting_does_not_allow_grant_type_password(self): post_data = self._password_grant_post_data() @@ -744,3 +722,34 @@ class TokenTestCase(TestCase): response = self._post_request(post_data) json.loads(response.content.decode('utf-8')) + + def test_client_credentials_grant_type(self): + fake_scopes_list = ['scopeone', 'scopetwo'] + + # Add scope for this client. + self.client.scope = fake_scopes_list + self.client.save() + + post_data = { + 'client_id': self.client.client_id, + 'client_secret': self.client.client_secret, + 'grant_type': 'client_credentials', + } + response = self._post_request(post_data) + response_dict = json.loads(response.content.decode('utf-8')) + + # Ensure access token exists in the response, also check if scopes are + # the ones we registered previously. + self.assertTrue('access_token' in response_dict) + self.assertEqual(' '.join(fake_scopes_list), response_dict['scope']) + + # Clean scopes for this client. + self.client.scope = '' + self.client.save() + + response = self._post_request(post_data) + response_dict = json.loads(response.content.decode('utf-8')) + + # It should fail when client does not have any scope added. + self.assertEqual(400, response.status_code) + self.assertEqual('invalid_scope', response_dict['error']) From 5dcb62d35c6e4c3734148701f59c9b5aa74b3403 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Sun, 8 Apr 2018 18:28:38 -0300 Subject: [PATCH 45/71] Improve docs. --- docs/sections/changelog.rst | 406 ++++++++++------------------------- docs/sections/contribute.rst | 2 +- docs/sections/oauth2.rst | 36 +++- 3 files changed, 155 insertions(+), 289 deletions(-) diff --git a/docs/sections/changelog.rst b/docs/sections/changelog.rst index 7265ab9..74e4d48 100644 --- a/docs/sections/changelog.rst +++ b/docs/sections/changelog.rst @@ -8,486 +8,318 @@ All notable changes to this project will be documented in this file. Unreleased ========== -**Added** - -* Add pep8 compliance and checker. - -**Changed** - -* Test suit now uses pytest. - -**Fixed** - -* Infinite callback loop in the check-session iframe. +* Added: OAuth2 grant_type client_credentials support. +* Added: pep8 compliance and checker. +* Changed: Test suit now uses pytest. +* Fixed: Infinite callback loop in the check-session iframe. 0.5.3 ===== *2018-03-09* -**Fixed** - -* Update project to support Django 2.0 +* Fixed: Update project to support Django 2.0 0.5.2 ===== *2017-08-22* -**Fixed** - -* Fix infinite login loop if "prompt=login" (#198) -* Fix Django 2.0 deprecation warnings (#185) +* Fixed: infinite login loop if "prompt=login" (#198) +* Fixed: Django 2.0 deprecation warnings (#185) 0.5.1 ===== *2017-07-11* -**Changed** - -* Documentation template changed to `Read The Docs`. - -**Fixed** - -* `install_requires` has not longer pinned versions. -* Removed infinity loop during authorization stage when `prompt=login` has been send. -* Changed `prompt` handling as set of options instead of regular string. -* Redirect URI must match exactly with given in query parameter. -* Stored user consent are useful for public clients too. -* Fixed documentation for custom scopes handling. -* Scopes during refresh and code exchange are being taken from authorization request and not from query parameters. +* Changed: Documentation template changed to `Read The Docs`. +* Fixed: `install_requires` has not longer pinned versions. +* Fixed: Removed infinity loop during authorization stage when `prompt=login` has been send. +* Fixed: Changed `prompt` handling as set of options instead of regular string. +* Fixed: Redirect URI must match exactly with given in query parameter. +* Fixed: Stored user consent are useful for public clients too. +* Fixed: documentation for custom scopes handling. +* Fixed: Scopes during refresh and code exchange are being taken from authorization request and not from query parameters. 0.5.0 ===== *2017-05-18* -**Added** - -* Signals when user accept/decline the authorization page. -* `OIDC_AFTER_END_SESSION_HOOK` setting for additional business logic. -* Feature granttype password. -* require_consent and reuse_consent are added to Client model. - -**Changed** - -* OIDC_SKIP_CONSENT_ALWAYS and OIDC_SKIP_CONSENT_ENABLE are removed from settings. - -**Fixed** - -* Timestamps with unixtime (instead of django timezone). -* Field refresh_token cannot be primary key if null. -* `create_uri_exceptions` are now being logged at `Exception` level not `DEBUG`. +* Added: signals when user accept/decline the authorization page. +* Added: `OIDC_AFTER_END_SESSION_HOOK` setting for additional business logic. +* Added: feature granttype password. +* Added: require_consent and reuse_consent are added to Client model. +* Changed: OIDC_SKIP_CONSENT_ALWAYS and OIDC_SKIP_CONSENT_ENABLE are removed from settings. +* Fixed: timestamps with unixtime (instead of django timezone). +* Fixed: field refresh_token cannot be primary key if null. +* Fixed: `create_uri_exceptions` are now being logged at `Exception` level not `DEBUG`. 0.4.4 ===== *2016-11-29* -**Fixed** - -* Bug in Session Management middleware when using Python 3. -* Translations handling. +* Fixed: Bug in Session Management middleware when using Python 3. +* Fixed: Translations handling. 0.4.3 ===== *2016-11-02* -**Added** - -* Session Management 1.0 support. -* post_logout_redirect_uris into admin. - -**Changed** - -* Package url names. -* Rename /logout/ url to /end-session/. - -**Fixed** - -* Bug when trying authorize with response_type id_token without openid scope. +* Added: Session Management 1.0 support. +* Added: post_logout_redirect_uris into admin. +* Changed: Package url names. +* Changed: Rename /logout/ url to /end-session/. +* Fixed: bug when trying authorize with response_type id_token without openid scope. 0.4.2 ===== *2016-10-13* -**Added** - -* Support for client redirect URIs with query strings. - -**Fixed** - -* Bug when generating secret_key value using admin. - -**Changed** - -* Client is available to OIDC_EXTRA_SCOPE_CLAIMS implementations via `self.client`. -* The constructor signature for `ScopeClaims` has changed, it now is called with the `Token` as its single argument. +* Added: support for client redirect URIs with query strings. +* Fixed: bug when generating secret_key value using admin. +* Changed: client is available to OIDC_EXTRA_SCOPE_CLAIMS implementations via `self.client`. +* Changed: the constructor signature for `ScopeClaims` has changed, it now is called with the `Token` as its single argument. 0.4.1 ===== *2016-10-03* -**Changed** - -* Update pyjwkest to version 1.3.0. -* Use Cryptodome instead of Crypto lib. +* Changed: update pyjwkest to version 1.3.0. +* Changed: use Cryptodome instead of Crypto lib. 0.4.0 ===== *2016-09-12* -**Added** - -* Support for Hybrid Flow. -* New attributes for Clients: Website url, logo, contact email, terms url. -* Polish translations. -* Examples section in documentation. - -**Fixed** - -* CORS in discovery and userinfo endpoint. -* Client type public bug when created using the admin. -* Missing OIDC_TOKEN_EXPIRE setting on implicit flow. +* Added: support for Hybrid Flow. +* Added: new attributes for Clients: Website url, logo, contact email, terms url. +* Added: polish translations. +* Added: examples section in documentation. +* Fixed: CORS in discovery and userinfo endpoint. +* Fixed: client type public bug when created using the admin. +* Fixed: missing OIDC_TOKEN_EXPIRE setting on implicit flow. 0.3.7 ===== *2016-08-31* -**Added** - -* Support for Django 1.10. -* Initial translation files (ES, FR). -* Support for at_hash parameter. - -**Fixed** - -* Empty address dict in userinfo response. +* Added: support for Django 1.10. +* Added: initial translation files (ES, FR). +* Added: support for at_hash parameter. +* Fixed: empty address dict in userinfo response. 0.3.6 ===== *2016-07-07* -**Changed** - -* OIDC_USERINFO setting. +* Changed: OIDC_USERINFO setting. 0.3.5 ===== *2016-06-21* -**Added** - -* Field date_given in UserConsent model. -* Verbose names to all model fields. -* Customize scopes names and descriptions on authorize template. - -**Changed** - -* OIDC_EXTRA_SCOPE_CLAIMS setting. +* Added: field date_given in UserConsent model. +* Added: verbose names to all model fields. +* Added: customize scopes names and descriptions on authorize template. +* Changed: OIDC_EXTRA_SCOPE_CLAIMS setting. 0.3.4 ===== *2016-06-10* -**Changed** - -* Make SITE_URL setting optional. - -**Fixed** - -* Missing migration. +* Changed: Make SITE_URL setting optional. +* Fixed: Missing migration. 0.3.3 ===== *2016-05-03* -**Fixed** - -* Important bug with PKCE and form submit in Auth Request. +* Fixed: Important bug with PKCE and form submit in Auth Request. 0.3.2 ===== *2016-04-26* -**Added** - -* Choose type of client on creation. -* Implement Proof Key for Code Exchange by OAuth Public Clients. -* Support for prompt parameter. -* Support for different client JWT tokens algorithm. - -**Fixed** - -* Not auto-approve requests for non-confidential clients (publics). +* Added: choose type of client on creation. +* Added: implement Proof Key for Code Exchange by OAuth Public Clients. +* Added: support for prompt parameter. +* Added: support for different client JWT tokens algorithm. +* Fixed: not auto-approve requests for non-confidential clients (publics). 0.3.1 ===== *2016-03-09* -**Fixed** - -* response_type was not being validated (OpenID request). +* Fixed: response_type was not being validated (OpenID request). 0.3.0 ===== *2016-02-23* -**Added** - -* Support OAuth2 requests. -* Decorator for protecting views with OAuth2. -* Setting OIDC_IDTOKEN_PROCESSING_HOOK. +* Added: support OAuth2 requests. +* Added: decorator for protecting views with OAuth2. +* Added: setting OIDC_IDTOKEN_PROCESSING_HOOK. 0.2.5 ===== *2016-02-03* -**Added** - -* Setting OIDC_SKIP_CONSENT_ALWAYS. - -**Changed** - -* Removing OIDC_RSA_KEY_FOLDER setting. Moving RSA Keys to the database. -* Update pyjwkest to version 1.1.0. - -**Fixed** - -* Nonce parameter missing on the decide form. -* Set Allow-Origin header to jwks endpoint. +* Added: Setting OIDC_SKIP_CONSENT_ALWAYS. +* Changed: Removing OIDC_RSA_KEY_FOLDER setting. Moving RSA Keys to the database. +* Changed: Update pyjwkest to version 1.1.0. +* Fixed: Nonce parameter missing on the decide form. +* Fixed: Set Allow-Origin header to jwks endpoint. 0.2.4 ===== *2016-01-20* -**Added** - -* Auto-generation of client ID and SECRET using the admin. -* Validate nonce parameter when using Implicit Flow. - -**Fixed** - -* Fixed generating RSA key by ignoring value of OIDC_RSA_KEY_FOLDER. -* Make OIDC_AFTER_USERLOGIN_HOOK and OIDC_IDTOKEN_SUB_GENERATOR to be lazy imported by the location of the function. -* Problem with a function that generate urls for the /.well-known/openid-configuration/ endpoint. +* Added: Auto-generation of client ID and SECRET using the admin. +* Added: Validate nonce parameter when using Implicit Flow. +* Fixed: generating RSA key by ignoring value of OIDC_RSA_KEY_FOLDER. +* Fixed: make OIDC_AFTER_USERLOGIN_HOOK and OIDC_IDTOKEN_SUB_GENERATOR to be lazy imported by the location of the function. +* Fixed: problem with a function that generate urls for the /.well-known/openid-configuration/ endpoint. 0.2.3 ===== *2016-01-06* -**Added** - -* Make user and client unique on UserConsent model. -* Support for URL's without end slash. - -**Changed** - -* Upgrade pyjwkest to version 1.0.8. - -**Fixed** - -* String format error in models. -* Redirect to non http urls fail (for Mobile Apps). +* Added: Make user and client unique on UserConsent model. +* Added: Support for URL's without end slash. +* Changed: Upgrade pyjwkest to version 1.0.8. +* Fixed: String format error in models. +* Fixed: Redirect to non http urls fail (for Mobile Apps). 0.2.1 ===== *2015-10-21* -**Added** - -* Refresh token flow. - -**Changed** - -* Upgrade pyjwkest to version >= 1.0.6. - -**Fixed** - -* Unicode error in Client model. -* Bug in creatersakey command (when using Python 3). -* Bug when updating pyjwkest version. +* Added: refresh token flow. +* Changed: upgrade pyjwkest to version >= 1.0.6. +* Fixed: Unicode error in Client model. +* Fixed: Bug in creatersakey command (when using Python 3). +* Fixed: Bug when updating pyjwkest version. 0.2.0 ===== *2015-09-25* -**Changed** - -* UserInfo model was removed. Now you can add your own model using OIDC_USERINFO setting. - -**Fixed** - -* ID token does NOT contain kid. +* Changed: UserInfo model was removed. Now you can add your own model using OIDC_USERINFO setting. +* Fixed: ID token does NOT contain kid. 0.1.2 ===== *2015-08-04* -**Added** - -* Add token_endpoint_auth_methods_supported to discovery. - -**Fixed** - -* Missing commands folder in setup file. +* Added: add token_endpoint_auth_methods_supported to discovery. +* Fixed: missing commands folder in setup file. 0.1.1 ===== *2015-07-31* -**Added** - -* Sending access_token as query string parameter in UserInfo Endpoint. -* Support HTTP Basic client authentication. - -**Changed** - -* Use models setting instead of User. - -**Fixed** - -* In python 2: "aud" and "nonce" parameters didn't appear in id_token. +* Added: sending access_token as query string parameter in UserInfo Endpoint. +* Added: support HTTP Basic client authentication. +* Changed: use models setting instead of User. +* Fixed: in python 2: "aud" and "nonce" parameters didn't appear in id_token. 0.1.0 ===== *2015-07-17* -**Added** - -* Now id tokens are signed/encrypted with RS256. -* Command for easily generate random RSA key. -* Jwks uri to discovery endpoint. -* id_token_signing_alg_values_supported to discovery endpoint. - -**Fixed** - -* Nonce support for both Code and Implicit flow. +* Added: now id tokens are signed/encrypted with RS256. +* Added: command for easily generate random RSA key. +* Added: jwks uri to discovery endpoint. +* Added: id_token_signing_alg_values_supported to discovery endpoint. +* Fixed: nonce support for both Code and Implicit flow. 0.0.7 ===== *2015-07-06* -**Added** +**** -* Support for Python 3. -* Way of remember user consent and skipt it (OIDC_SKIP_CONSENT_ENABLE). -* Setting OIDC_SKIP_CONSENT_EXPIRE. - -**Changed** - -* Now OIDC_EXTRA_SCOPE_CLAIMS must be a string, to be lazy imported. +* Added: support for Python 3. +* Added: way of remember user consent and skipt it (OIDC_SKIP_CONSENT_ENABLE). +* Added: setting OIDC_SKIP_CONSENT_EXPIRE. +* Changed: now OIDC_EXTRA_SCOPE_CLAIMS must be a string, to be lazy imported. 0.0.6 ===== *2015-06-16* -**Added** - -* Better naming for models in the admin. - -**Changed** - -* Now tests run without the need of a project configured. - -**Fixed** - -* Error when returning address_formatted claim. +* Added: better naming for models in the admin. +* Changed: now tests run without the need of a project configured. +* Fixed: error when returning address_formatted claim. 0.0.5 ===== *2015-05-09* -**Added** - -* Support for Django 1.8. - -**Fixed** - -* Validation of scope in UserInfo endpoint. +* Added: support for Django 1.8. +* Fixed: validation of scope in UserInfo endpoint. 0.0.4 ===== *2015-04-22* -**Added** - -* Initial migrations. - -**Fixed** - -* Important bug with id_token when using implicit flow. -* Validate Code expiration in Auth Code Flow. -* Validate Access Token expiration in UserInfo endpoint. +* Added: initial migrations. +* Fixed: important bug with id_token when using implicit flow. +* Fixed: validate Code expiration in Auth Code Flow. +* Fixed: validate Access Token expiration in UserInfo endpoint. 0.0.3 ===== *2015-04-15* -**Added** - -* Normalize gender field in UserInfo. - -**Changed** - -* Make address_formatted a property inside UserInfo. - -**Fixed** - -* Important bug in claims response. +* Added: normalize gender field in UserInfo. +* Changed: make address_formatted a property inside UserInfo. +* Fixed: important bug in claims response. 0.0.2 ===== *2015-03-26* -**Added** - -* Setting OIDC_AFTER_USERLOGIN_HOOK. - -**Fixed** - -* Tests failing because an incorrect tag in one template. +* Added: setting OIDC_AFTER_USERLOGIN_HOOK. +* Fixed: tests failing because an incorrect tag in one template. 0.0.1 ===== *2015-03-13* -**Added** - -* Provider Configuration Information endpoint. -* Setting OIDC_IDTOKEN_SUB_GENERATOR. - -**Changed** - -* Now use setup in OIDC_EXTRA_SCOPE_CLAIMS setting. +* Added: provider Configuration Information endpoint. +* Added: setting OIDC_IDTOKEN_SUB_GENERATOR. +* Changed: now use setup in OIDC_EXTRA_SCOPE_CLAIMS setting. 0.0.0 ===== diff --git a/docs/sections/contribute.rst b/docs/sections/contribute.rst index 068783a..db10819 100644 --- a/docs/sections/contribute.rst +++ b/docs/sections/contribute.rst @@ -24,7 +24,7 @@ Use `tox `_ for running tests in each of the e # Run single test file on specific environment. $ tox -e py35-django20 tests/cases/test_authorize_endpoint.py -We also use `travis `_ to automatically test every commit to the project, +We also use `travis `_ to automatically test every commit to the project. Improve Documentation ===================== diff --git a/docs/sections/oauth2.rst b/docs/sections/oauth2.rst index b780b45..0930a1f 100644 --- a/docs/sections/oauth2.rst +++ b/docs/sections/oauth2.rst @@ -17,7 +17,7 @@ Here we are going to protect a view with a scope called ``testscope``:: @require_http_methods(['GET']) - @protected_resource_view(['testscope']) + @protected_resource_view(['read_books']) def protected_api(request, *args, **kwargs): dic = { @@ -25,3 +25,37 @@ Here we are going to protect a view with a scope called ``testscope``:: } return JsonResponse(dic, status=200) + +Client Credentials Grant +======================== + +The client can request an access token using only its client credentials (ID and SECRET) when the client is requesting access to the protected resources under its control, that have been previously arranged with the authorization server using the ``client.scope`` field. + +.. note:: + You can use Django admin to manually set the client scope or programmatically:: + + client.scope = ['read_books', 'add_books'] + client.save() + +This is how the request should look like:: + + POST /token HTTP/1.1 + Host: localhost:8000 + Authorization: Basic eWZ3a3c0cWxtaHY0cToyVWE0QjVzRlhmZ3pNeXR5d1FqT01jNUsxYmpWeXhXeXRySVdsTmpQbld3\ + Content-Type: application/x-www-form-urlencoded + + grant_type=client_credentials + +A successful access token response will like this:: + + HTTP/1.1 200 OK + Content-Type: application/json + Cache-Control: no-store + Pragma: no-cache + + { + "token_type" : "Bearer", + "access_token" : "eyJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ.eyJzY3AiOlsib3BlbmlkIiw...", + "expires_in" : 3600, + "scope" : "read_books add_books" + } From 42df40c16d0dd9cca710f7bdd26ccb5de0ea5f70 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Sun, 8 Apr 2018 18:29:10 -0300 Subject: [PATCH 46/71] Fix docs. --- docs/sections/oauth2.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sections/oauth2.rst b/docs/sections/oauth2.rst index 0930a1f..6dde5cc 100644 --- a/docs/sections/oauth2.rst +++ b/docs/sections/oauth2.rst @@ -8,7 +8,7 @@ Because OIDC is a layer on top of the OAuth 2.0 protocol, this package also give Protecting Views ================ -Here we are going to protect a view with a scope called ``testscope``:: +Here we are going to protect a view with a scope called ``read_books``:: from django.http import JsonResponse from django.views.decorators.http import require_http_methods From ac7bd336a8f1c109dd5520f2e3336be746ff26d6 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Mon, 9 Apr 2018 20:20:33 -0300 Subject: [PATCH 47/71] Add protected_resource_view test using client_credentials. --- .../tests/cases/test_token_endpoint.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/oidc_provider/tests/cases/test_token_endpoint.py b/oidc_provider/tests/cases/test_token_endpoint.py index 2ce8555..dad6d4b 100644 --- a/oidc_provider/tests/cases/test_token_endpoint.py +++ b/oidc_provider/tests/cases/test_token_endpoint.py @@ -9,6 +9,7 @@ except ImportError: from urllib import urlencode from django.core.management import call_command +from django.http import JsonResponse try: from django.urls import reverse except ImportError: @@ -18,11 +19,13 @@ from django.test import ( override_settings, ) from django.test import TestCase +from django.views.decorators.http import require_http_methods from jwkest.jwk import KEYS from jwkest.jws import JWS from jwkest.jwt import JWT from mock import patch +from oidc_provider.lib.utils.oauth2 import protected_resource_view from oidc_provider.lib.utils.token import create_code from oidc_provider.models import Token from oidc_provider.tests.app.utils import ( @@ -743,6 +746,24 @@ class TokenTestCase(TestCase): self.assertTrue('access_token' in response_dict) self.assertEqual(' '.join(fake_scopes_list), response_dict['scope']) + # Create a protected resource and test the access_token. + + @require_http_methods(['GET']) + @protected_resource_view(fake_scopes_list) + def protected_api(request, *args, **kwargs): + return JsonResponse({'protected': 'information'}, status=200) + + # Deploy view on some url. So, base url could be anything. + request = self.factory.get( + '/api/protected/?access_token={0}'.format(response_dict['access_token'])) + response = protected_api(request) + response_dict = json.loads(response.content.decode('utf-8')) + + self.assertEqual(response.status_code, 200) + self.assertTrue('protected' in response_dict) + + # Protected resource test ends here. + # Clean scopes for this client. self.client.scope = '' self.client.save() From 22e9ee8675d559bb829d92545333331d4ebe5f35 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Tue, 10 Apr 2018 21:51:39 -0300 Subject: [PATCH 48/71] 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 74e4d48..61494d8 100644 --- a/docs/sections/changelog.rst +++ b/docs/sections/changelog.rst @@ -10,6 +10,7 @@ Unreleased * Added: OAuth2 grant_type client_credentials support. * Added: pep8 compliance and checker. +* Added: Setting OIDC_IDTOKEN_INCLUDE_CLAIMS supporting claims inside id_token. * Changed: Test suit now uses pytest. * Fixed: Infinite callback loop in the check-session iframe. From 9b7be87dad74f5035cf90f8415defc613c13ec7e Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Tue, 10 Apr 2018 21:53:29 -0300 Subject: [PATCH 49/71] Edit changelog. --- docs/sections/changelog.rst | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/sections/changelog.rst b/docs/sections/changelog.rst index 61494d8..831d19c 100644 --- a/docs/sections/changelog.rst +++ b/docs/sections/changelog.rst @@ -34,10 +34,10 @@ Unreleased *2017-07-11* -* Changed: Documentation template changed to `Read The Docs`. -* Fixed: `install_requires` has not longer pinned versions. -* Fixed: Removed infinity loop during authorization stage when `prompt=login` has been send. -* Fixed: Changed `prompt` handling as set of options instead of regular string. +* Changed: Documentation template changed to Read The Docs. +* Fixed: install_requires has not longer pinned versions. +* Fixed: Removed infinity loop during authorization stage when prompt=login has been send. +* Fixed: Changed prompt handling as set of options instead of regular string. * Fixed: Redirect URI must match exactly with given in query parameter. * Fixed: Stored user consent are useful for public clients too. * Fixed: documentation for custom scopes handling. @@ -49,13 +49,13 @@ Unreleased *2017-05-18* * Added: signals when user accept/decline the authorization page. -* Added: `OIDC_AFTER_END_SESSION_HOOK` setting for additional business logic. +* Added: OIDC_AFTER_END_SESSION_HOOK setting for additional business logic. * Added: feature granttype password. * Added: require_consent and reuse_consent are added to Client model. * Changed: OIDC_SKIP_CONSENT_ALWAYS and OIDC_SKIP_CONSENT_ENABLE are removed from settings. * Fixed: timestamps with unixtime (instead of django timezone). * Fixed: field refresh_token cannot be primary key if null. -* Fixed: `create_uri_exceptions` are now being logged at `Exception` level not `DEBUG`. +* Fixed: create_uri_exceptions are now being logged at Exception level not DEBUG. 0.4.4 ===== @@ -83,8 +83,8 @@ Unreleased * Added: support for client redirect URIs with query strings. * Fixed: bug when generating secret_key value using admin. -* Changed: client is available to OIDC_EXTRA_SCOPE_CLAIMS implementations via `self.client`. -* Changed: the constructor signature for `ScopeClaims` has changed, it now is called with the `Token` as its single argument. +* Changed: client is available to OIDC_EXTRA_SCOPE_CLAIMS implementations via self.client. +* Changed: the constructor signature for ScopeClaims has changed, it now is called with the Token as its single argument. 0.4.1 ===== From 9534ff49bb1e754499f18278743d4174e7737436 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Tue, 10 Apr 2018 22:04:24 -0300 Subject: [PATCH 50/71] Edit README and contribute doc. --- README.md | 14 ++------------ docs/sections/contribute.rst | 11 +++++++---- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 0aafd1a..6223d9a 100644 --- a/README.md +++ b/README.md @@ -15,15 +15,5 @@ OpenID Connect is a simple identity layer on top of the OAuth 2.0 protocol, whic Support for Python 3 and 2. Also latest versions of django. -[Read docs for more info](http://django-oidc-provider.readthedocs.org/). - -## Contributing - -[Join us!](https://github.com/juanifioren/django-oidc-provider/graphs/contributors) we love contributions, so please feel free to fix bugs, improve things, provide documentation. You SHOULD follow this steps: - -* Fork the project and create new branch from `develop`. -* Make your feature addition or bug fix. -* Add tests for it inside `oidc_provider/tests`. Then run all and ensure everything is OK (read docs for how to test in all envs). -* Create an issue and explain your changes. -* Create pull request for the issue to the `develop` branch. -* Wait for the collaborators to review it. +[Read documentation for more info.](http://django-oidc-provider.readthedocs.org/) +[Do you want to contribute? Please read this.](http://django-oidc-provider.readthedocs.io/en/latest/sections/contribute.html) diff --git a/docs/sections/contribute.rst b/docs/sections/contribute.rst index db10819..e67769c 100644 --- a/docs/sections/contribute.rst +++ b/docs/sections/contribute.rst @@ -3,12 +3,15 @@ Contribute ########## -We love contributions, so please feel free to fix bugs, improve things, provide documentation. You SHOULD follow this steps: +We love contributions, so please feel free to fix bugs, improve things, provide documentation. These are the steps: -* Fork the project. +* Create an issue and explain your feature/bugfix. +* Wait collaborators comments. +* Fork the project and create new branch from `develop`. * Make your feature addition or bug fix. -* Add tests for it inside ``oidc_provider/tests``. Then run all tests and ensure everything is OK (see the section below on how to test in all envs). -* Send pull request to the specific version branch. +* Add tests and documentation if needed. +* Create pull request for the issue to the `develop` branch. +* Wait collaborators reviews. Running Tests ============= From 6a74e913dc868d872ec832781f404fbc9d86ba70 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Tue, 10 Apr 2018 22:25:07 -0300 Subject: [PATCH 51/71] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 6223d9a..c6bb6cd 100644 --- a/README.md +++ b/README.md @@ -16,4 +16,5 @@ OpenID Connect is a simple identity layer on top of the OAuth 2.0 protocol, whic Support for Python 3 and 2. Also latest versions of django. [Read documentation for more info.](http://django-oidc-provider.readthedocs.org/) + [Do you want to contribute? Please read this.](http://django-oidc-provider.readthedocs.io/en/latest/sections/contribute.html) From 776f0406244e7cf52d4fa7caa34933dde7b813ad Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Fri, 13 Apr 2018 16:36:51 -0300 Subject: [PATCH 52/71] Fix BaseCodeTokenModel and user attr. --- ...5_client_credentials.py => 0025_user_field_codetoken.py} | 2 +- oidc_provider/models.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) rename oidc_provider/migrations/{0025_client_credentials.py => 0025_user_field_codetoken.py} (94%) diff --git a/oidc_provider/migrations/0025_client_credentials.py b/oidc_provider/migrations/0025_user_field_codetoken.py similarity index 94% rename from oidc_provider/migrations/0025_client_credentials.py rename to oidc_provider/migrations/0025_user_field_codetoken.py index 5ca3c4e..d757fb0 100644 --- a/oidc_provider/migrations/0025_client_credentials.py +++ b/oidc_provider/migrations/0025_user_field_codetoken.py @@ -1,4 +1,4 @@ -# Generated by Django 2.0.3 on 2018-04-07 21:51 +# Generated by Django 2.0.3 on 2018-04-13 19:34 from django.conf import settings from django.db import migrations, models diff --git a/oidc_provider/models.py b/oidc_provider/models.py index 0ca6b12..411633c 100644 --- a/oidc_provider/models.py +++ b/oidc_provider/models.py @@ -130,8 +130,6 @@ class Client(models.Model): class BaseCodeTokenModel(models.Model): - user = models.ForeignKey( - settings.AUTH_USER_MODEL, verbose_name=_(u'User'), on_delete=models.CASCADE) client = models.ForeignKey(Client, verbose_name=_(u'Client'), on_delete=models.CASCADE) expires_at = models.DateTimeField(verbose_name=_(u'Expiration Date')) _scope = models.TextField(default='', verbose_name=_(u'Scopes')) @@ -159,6 +157,8 @@ class BaseCodeTokenModel(models.Model): class Code(BaseCodeTokenModel): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, verbose_name=_(u'User'), on_delete=models.CASCADE) code = models.CharField(max_length=255, unique=True, verbose_name=_(u'Code')) nonce = models.CharField(max_length=255, blank=True, default='', verbose_name=_(u'Nonce')) is_authentication = models.BooleanField(default=False, verbose_name=_(u'Is Authentication?')) @@ -206,6 +206,8 @@ class Token(BaseCodeTokenModel): class UserConsent(BaseCodeTokenModel): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, verbose_name=_(u'User'), on_delete=models.CASCADE) date_given = models.DateTimeField(verbose_name=_(u'Date Given')) class Meta: From c06bf43f22728aa1d19d359418778e6bb212a2aa Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Fri, 13 Apr 2018 16:48:06 -0300 Subject: [PATCH 53/71] Bump version v0.6.0. --- docs/sections/changelog.rst | 5 +++++ setup.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/sections/changelog.rst b/docs/sections/changelog.rst index 831d19c..0c5f0f6 100644 --- a/docs/sections/changelog.rst +++ b/docs/sections/changelog.rst @@ -8,6 +8,11 @@ All notable changes to this project will be documented in this file. Unreleased ========== +0.6.0 +===== + +*2018-04-13* + * Added: OAuth2 grant_type client_credentials support. * Added: pep8 compliance and checker. * Added: Setting OIDC_IDTOKEN_INCLUDE_CLAIMS supporting claims inside id_token. diff --git a/setup.py b/setup.py index 02d7f45..7d804ad 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) setup( name='django-oidc-provider', - version='0.5.3', + version='0.6.0', packages=find_packages(), include_package_data=True, license='MIT License', From 61d88014c9ee8c36832afd56fc422559f40aaed2 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Fri, 20 Apr 2018 11:19:59 -0300 Subject: [PATCH 54/71] 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 55/71] 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 56/71] 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 57/71] 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 58/71] 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 59/71] 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 60/71] 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 61/71] 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 62/71] 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 63/71] 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 64/71] 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 65/71] 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 66/71] 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 67/71] 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 68/71] 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 69/71] 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 70/71] 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 71/71] 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