From 1a74bcbc5cdc10a40bd6b252ef59836b6c39c959 Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Mon, 4 Apr 2016 17:19:49 -0300 Subject: [PATCH 01/23] Add client type to client creation form. --- .../migrations/0011_client_client_type.py | 20 +++++++++++++++++++ oidc_provider/models.py | 9 +++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 oidc_provider/migrations/0011_client_client_type.py diff --git a/oidc_provider/migrations/0011_client_client_type.py b/oidc_provider/migrations/0011_client_client_type.py new file mode 100644 index 0000000..26e9fc3 --- /dev/null +++ b/oidc_provider/migrations/0011_client_client_type.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2016-04-04 19:56 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oidc_provider', '0010_code_is_authentication'), + ] + + operations = [ + 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), + ), + ] diff --git a/oidc_provider/models.py b/oidc_provider/models.py index 68ace73..69dcc39 100644 --- a/oidc_provider/models.py +++ b/oidc_provider/models.py @@ -10,6 +10,11 @@ from django.conf import settings class Client(models.Model): + CLIENT_TYPE_CHOICES = [ + ('confidential', 'Confidential'), + ('public', 'Public'), + ] + RESPONSE_TYPE_CHOICES = [ ('code', 'code (Authorization Code Flow)'), ('id_token', 'id_token (Implicit Flow)'), @@ -17,10 +22,10 @@ class Client(models.Model): ] name = models.CharField(max_length=100, default='') + client_type = models.CharField(max_length=30, choices=CLIENT_TYPE_CHOICES, default='confidential', help_text=_(u'Confidential clients are capable of maintaining the confidentiality of their credentials. Public clients are incapable.')) client_id = models.CharField(max_length=255, unique=True) client_secret = models.CharField(max_length=255, unique=True) - response_type = models.CharField(max_length=30, - choices=RESPONSE_TYPE_CHOICES) + response_type = models.CharField(max_length=30, choices=RESPONSE_TYPE_CHOICES) date_created = models.DateField(auto_now_add=True) _redirect_uris = models.TextField(default='', verbose_name=_(u'Redirect URI'), help_text=_(u'Enter each URI on a new line.')) From fa2c7d314de541a435ab205b1120fb345074756f Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Mon, 4 Apr 2016 17:25:28 -0300 Subject: [PATCH 02/23] Edit CHANGELOG. --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2c88b7..7a2b927 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. ### [Unreleased] +##### Added +- Choose type of client on creation. + ### [0.3.1] - 2016-03-09 ##### Fixed From a3247db273fd26b787dca71432cf3f9b699a5882 Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Tue, 5 Apr 2016 18:31:08 -0300 Subject: [PATCH 03/23] Improve handle of client_secret with client_types. --- oidc_provider/admin.py | 15 ++++++++++++-- .../migrations/0012_auto_20160405_2041.py | 20 +++++++++++++++++++ oidc_provider/models.py | 2 +- 3 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 oidc_provider/migrations/0012_auto_20160405_2041.py diff --git a/oidc_provider/admin.py b/oidc_provider/admin.py index 9963b35..9b3bd0a 100644 --- a/oidc_provider/admin.py +++ b/oidc_provider/admin.py @@ -30,10 +30,21 @@ class ClientForm(ModelForm): def clean_client_secret(self): instance = getattr(self, 'instance', None) + + secret = '' + + print self.cleaned_data + if instance and instance.pk: - return instance.client_secret + if (self.cleaned_data['client_type'] == 'confidential') and not instance.client_secret: + secret = md5(uuid4().hex.encode()).hexdigest() + elif (self.cleaned_data['client_type'] == 'confidential') and instance.client_secret: + secret = instance.client_secret else: - return md5(uuid4().hex.encode()).hexdigest() + if (instance.client_type == 'confidential'): + secret = md5(uuid4().hex.encode()).hexdigest() + + return secret @admin.register(Client) diff --git a/oidc_provider/migrations/0012_auto_20160405_2041.py b/oidc_provider/migrations/0012_auto_20160405_2041.py new file mode 100644 index 0000000..c04b613 --- /dev/null +++ b/oidc_provider/migrations/0012_auto_20160405_2041.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2016-04-05 20:41 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oidc_provider', '0011_client_client_type'), + ] + + operations = [ + migrations.AlterField( + model_name='client', + name='client_secret', + field=models.CharField(blank=True, default=b'', max_length=255), + ), + ] diff --git a/oidc_provider/models.py b/oidc_provider/models.py index 69dcc39..8d9ad39 100644 --- a/oidc_provider/models.py +++ b/oidc_provider/models.py @@ -24,7 +24,7 @@ class Client(models.Model): name = models.CharField(max_length=100, default='') client_type = models.CharField(max_length=30, choices=CLIENT_TYPE_CHOICES, default='confidential', help_text=_(u'Confidential clients are capable of maintaining the confidentiality of their credentials. Public clients are incapable.')) client_id = models.CharField(max_length=255, unique=True) - client_secret = models.CharField(max_length=255, unique=True) + client_secret = models.CharField(max_length=255, blank=True, default='') response_type = models.CharField(max_length=30, choices=RESPONSE_TYPE_CHOICES) date_created = models.DateField(auto_now_add=True) From 2c4ab6695e2f781ee2314637a2509f6a59e2f0a6 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Tue, 5 Apr 2016 19:08:49 -0300 Subject: [PATCH 04/23] Removing print. --- oidc_provider/admin.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/oidc_provider/admin.py b/oidc_provider/admin.py index 9b3bd0a..2a4fc61 100644 --- a/oidc_provider/admin.py +++ b/oidc_provider/admin.py @@ -33,8 +33,6 @@ class ClientForm(ModelForm): secret = '' - print self.cleaned_data - if instance and instance.pk: if (self.cleaned_data['client_type'] == 'confidential') and not instance.client_secret: secret = md5(uuid4().hex.encode()).hexdigest() From 6e8af74f76ec327ea2cba7cca7987b6ad3bba944 Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Wed, 6 Apr 2016 18:03:30 -0300 Subject: [PATCH 05/23] First intent to implement PKCE. --- oidc_provider/lib/endpoints/authorize.py | 14 ++++++++++++-- oidc_provider/lib/endpoints/token.py | 24 +++++++++++++++++++++--- oidc_provider/lib/utils/token.py | 17 +++++++++++++++-- 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/oidc_provider/lib/endpoints/authorize.py b/oidc_provider/lib/endpoints/authorize.py index 56972a4..3bb6409 100644 --- a/oidc_provider/lib/endpoints/authorize.py +++ b/oidc_provider/lib/endpoints/authorize.py @@ -56,6 +56,10 @@ class AuthorizeEndpoint(object): self.params.state = query_dict.get('state', '') self.params.nonce = query_dict.get('nonce', '') + # PKCE parameters. + self.params.code_challenge = query_dict.get('code_challenge') + self.params.code_challenge_method = query_dict.get('code_challenge_method') + def validate_params(self): try: self.client = Client.objects.get(client_id=self.params.client_id) @@ -85,7 +89,11 @@ class AuthorizeEndpoint(object): if not (clean_redirect_uri in self.client.redirect_uris): logger.debug('[Authorize] Invalid redirect uri: %s', self.params.redirect_uri) raise RedirectUriError() - + + # PKCE validation of the transformation method. + if self.params.code_challenge and self.params.code_challenge_method: + if not (self.params.code_challenge_method in ['plain', 'S256']): + raise AuthorizeError(self.params.redirect_uri, 'invalid_request', self.grant_type) def create_response_uri(self): uri = urlsplit(self.params.redirect_uri) @@ -99,7 +107,9 @@ class AuthorizeEndpoint(object): client=self.client, scope=self.params.scope, nonce=self.params.nonce, - is_authentication=self.is_authentication) + is_authentication=self.is_authentication, + code_challenge=self.params.code_challenge, + code_challenge_method=self.params.code_challenge_method) code.save() diff --git a/oidc_provider/lib/endpoints/token.py b/oidc_provider/lib/endpoints/token.py index a981eee..4acb005 100644 --- a/oidc_provider/lib/endpoints/token.py +++ b/oidc_provider/lib/endpoints/token.py @@ -1,4 +1,5 @@ -from base64 import b64decode +from base64 import b64decode, urlsafe_b64encode +import hashlib import logging import re try: @@ -6,6 +7,7 @@ try: except ImportError: from urllib import unquote +from Crypto.Cipher import AES from django.http import JsonResponse from oidc_provider.lib.errors import * @@ -30,14 +32,16 @@ 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', '')) + self.params.redirect_uri = unquote(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', '') self.params.scope = self.request.POST.get('scope', '') self.params.refresh_token = self.request.POST.get('refresh_token', '') + # PKCE parameters. + self.params.code_verifier = self.request.POST.get('code_verifier') + def _extract_client_auth(self): """ Get client credentials using HTTP Basic Authentication method. @@ -90,6 +94,20 @@ class TokenEndpoint(object): self.params.redirect_uri) raise TokenError('invalid_grant') + # Validate PKCE parameters. + if self.params.code_verifier: + obj = AES.new(settings.SECRET_KEY, AES.MODE_CBC) + code_challenge, code_challenge_method = tuple(obj.decrypt(self.code.code.decode('hex')).split(':')) + + if code_challenge_method == 'S256': + new_code_challenge = urlsafe_b64encode(hashlib.sha256(self.params.code_verifier.encode('ascii')).digest()).replace('=', '') + else: + new_code_challenge = self.params.code_verifier + + # TODO: We should explain the error. + if not (new_code_challenge == code_challenge): + raise TokenError('invalid_grant') + elif self.params.grant_type == 'refresh_token': if not self.params.refresh_token: logger.debug('[Token] Missing refresh token') diff --git a/oidc_provider/lib/utils/token.py b/oidc_provider/lib/utils/token.py index e512326..308364f 100644 --- a/oidc_provider/lib/utils/token.py +++ b/oidc_provider/lib/utils/token.py @@ -2,6 +2,7 @@ from datetime import timedelta import time import uuid +from Crypto.Cipher import AES from Crypto.PublicKey.RSA import importKey from django.utils import timezone from hashlib import md5 @@ -95,7 +96,8 @@ def create_token(user, client, id_token_dic, scope): return token -def create_code(user, client, scope, nonce, is_authentication): +def create_code(user, client, scope, nonce, is_authentication, + code_challenge=None, code_challenge_method=None): """ Create and populate a Code object. @@ -104,7 +106,18 @@ def create_code(user, client, scope, nonce, is_authentication): code = Code() code.user = user code.client = client - code.code = uuid.uuid4().hex + + if not code_challenge: + code.code = uuid.uuid4().hex + else: + obj = AES.new(settings.SECRET_KEY, AES.MODE_CBC) + + # Default is 'plain' method. + code_challenge_method = 'plain' if not code_challenge_method else code_challenge_method + + ciphertext = obj.encrypt(code_challenge + ':' + code_challenge_method) + code.code = ciphertext.encode('hex') + code.expires_at = timezone.now() + timedelta( seconds=settings.get('OIDC_CODE_EXPIRE')) code.scope = scope From 9c071831fa1726d95a701c5d9ae4097e4887a074 Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Wed, 6 Apr 2016 18:12:19 -0300 Subject: [PATCH 06/23] Edit CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a2b927..f865fa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. ##### Added - Choose type of client on creation. +- Implement Proof Key for Code Exchange by OAuth Public Clients. ### [0.3.1] - 2016-03-09 From 93c0bc2382174d274fe501f5a014f017bab475a9 Mon Sep 17 00:00:00 2001 From: Ignacio Date: Wed, 6 Apr 2016 21:28:13 -0300 Subject: [PATCH 07/23] Fix in example project. --- example_project/provider_app/settings.py | 2 + .../provider_app/templates/base.html | 5 +-- .../templates/oidc_provider/authorize.html | 40 ++++++++++--------- .../templates/oidc_provider/error.html | 18 +++++---- example_project/provider_app/urls.py | 4 +- 5 files changed, 38 insertions(+), 31 deletions(-) diff --git a/example_project/provider_app/settings.py b/example_project/provider_app/settings.py index d2a28af..ec0c26f 100644 --- a/example_project/provider_app/settings.py +++ b/example_project/provider_app/settings.py @@ -87,3 +87,5 @@ LOGIN_REDIRECT_URL = '/' # OIDC Provider settings SITE_URL = 'http://localhost:8000' + +LOGIN_URL = '/admin/login/' diff --git a/example_project/provider_app/templates/base.html b/example_project/provider_app/templates/base.html index d0c1cf1..92ef156 100644 --- a/example_project/provider_app/templates/base.html +++ b/example_project/provider_app/templates/base.html @@ -19,13 +19,12 @@ django-oidc-provider diff --git a/example_project/provider_app/templates/oidc_provider/authorize.html b/example_project/provider_app/templates/oidc_provider/authorize.html index 6df2ed5..6bbab77 100644 --- a/example_project/provider_app/templates/oidc_provider/authorize.html +++ b/example_project/provider_app/templates/oidc_provider/authorize.html @@ -2,25 +2,29 @@ {% block content %} -
-
-
-

Request for Permission

-

Client {{ client.name }} would like to access this information of you.

-
- {% csrf_token %} - {{ hidden_inputs }} -
- {% for scope in params.scope %} -
{{ scope | capfirst }}
- {% endfor %} +
+
+
+
+
+

Request for Permission

+

Client {{ client.name }} would like to access this information of you.

+ + {% csrf_token %} + {{ hidden_inputs }} +
+ {% for scope in params.scope %} +
{{ scope | capfirst }}
+ {% endfor %} +
+
+ +
+ +
+
-
- -
- -
- +
diff --git a/example_project/provider_app/templates/oidc_provider/error.html b/example_project/provider_app/templates/oidc_provider/error.html index 1dfc227..ce1eeb6 100644 --- a/example_project/provider_app/templates/oidc_provider/error.html +++ b/example_project/provider_app/templates/oidc_provider/error.html @@ -2,13 +2,17 @@ {% block content %} -
-
-
- -
-
{{ error }}
-

{{ description }}

+
+
+
+
+
+ +
+
{{ error }}
+

{{ description }}

+
+
diff --git a/example_project/provider_app/urls.py b/example_project/provider_app/urls.py index 1757fa8..60d246c 100644 --- a/example_project/provider_app/urls.py +++ b/example_project/provider_app/urls.py @@ -6,10 +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'^openid/', include('oidc_provider.urls', namespace='oidc_provider')), + url(r'^', include('oidc_provider.urls', namespace='oidc_provider')), url(r'^admin/', include(admin.site.urls)), ] From b1b8247cb082fde6bcc59ecea3fa38613c64ebc2 Mon Sep 17 00:00:00 2001 From: Ignacio Date: Thu, 7 Apr 2016 11:45:35 -0300 Subject: [PATCH 08/23] Add hidden inputs for PKCE. Fix bug with AES. --- oidc_provider/lib/endpoints/token.py | 3 ++- oidc_provider/lib/utils/token.py | 3 ++- oidc_provider/templates/oidc_provider/hidden_inputs.html | 4 +++- oidc_provider/tests/app/utils.py | 1 + oidc_provider/tests/test_authorize_endpoint.py | 8 ++++++++ oidc_provider/views.py | 1 - 6 files changed, 16 insertions(+), 4 deletions(-) diff --git a/oidc_provider/lib/endpoints/token.py b/oidc_provider/lib/endpoints/token.py index 4acb005..3c703c9 100644 --- a/oidc_provider/lib/endpoints/token.py +++ b/oidc_provider/lib/endpoints/token.py @@ -9,6 +9,7 @@ except ImportError: from Crypto.Cipher import AES from django.http import JsonResponse +from django.conf import settings as django_settings from oidc_provider.lib.errors import * from oidc_provider.lib.utils.params import * @@ -96,7 +97,7 @@ class TokenEndpoint(object): # Validate PKCE parameters. if self.params.code_verifier: - obj = AES.new(settings.SECRET_KEY, AES.MODE_CBC) + obj = AES.new(hashlib.md5(django_settings.SECRET_KEY).hexdigest(), AES.MODE_CBC) code_challenge, code_challenge_method = tuple(obj.decrypt(self.code.code.decode('hex')).split(':')) if code_challenge_method == 'S256': diff --git a/oidc_provider/lib/utils/token.py b/oidc_provider/lib/utils/token.py index 308364f..b7260a3 100644 --- a/oidc_provider/lib/utils/token.py +++ b/oidc_provider/lib/utils/token.py @@ -4,6 +4,7 @@ import uuid from Crypto.Cipher import AES from Crypto.PublicKey.RSA import importKey +from django.conf import settings as django_settings from django.utils import timezone from hashlib import md5 from jwkest.jwk import RSAKey as jwk_RSAKey @@ -110,7 +111,7 @@ def create_code(user, client, scope, nonce, is_authentication, if not code_challenge: code.code = uuid.uuid4().hex else: - obj = AES.new(settings.SECRET_KEY, AES.MODE_CBC) + obj = AES.new(md5(django_settings.SECRET_KEY).hexdigest(), AES.MODE_CBC) # Default is 'plain' method. code_challenge_method = 'plain' if not code_challenge_method else code_challenge_method diff --git a/oidc_provider/templates/oidc_provider/hidden_inputs.html b/oidc_provider/templates/oidc_provider/hidden_inputs.html index 59c7035..2bff39d 100644 --- a/oidc_provider/templates/oidc_provider/hidden_inputs.html +++ b/oidc_provider/templates/oidc_provider/hidden_inputs.html @@ -3,4 +3,6 @@ - \ No newline at end of file + + + diff --git a/oidc_provider/tests/app/utils.py b/oidc_provider/tests/app/utils.py index bd3989d..5164f4f 100644 --- a/oidc_provider/tests/app/utils.py +++ b/oidc_provider/tests/app/utils.py @@ -13,6 +13,7 @@ from oidc_provider.models import * FAKE_NONCE = 'cb584e44c43ed6bd0bc2d9c7e242837d' FAKE_RANDOM_STRING = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(32)) +FAKE_CODE_CHALLENGE = 'pG6flQqJa7INfIKb5cZVAXhTqvTKehIck6aQhdUuyWc' def create_fake_user(): diff --git a/oidc_provider/tests/test_authorize_endpoint.py b/oidc_provider/tests/test_authorize_endpoint.py index fc92fcc..5a3eaa2 100644 --- a/oidc_provider/tests/test_authorize_endpoint.py +++ b/oidc_provider/tests/test_authorize_endpoint.py @@ -122,6 +122,9 @@ class AuthorizationCodeFlowTestCase(TestCase): 'redirect_uri': self.client.default_redirect_uri, 'scope': 'openid email', 'state': self.state, + # PKCE parameters. + 'code_challenge': FAKE_CODE_CHALLENGE, + 'code_challenge_method': 'S256', }).replace('+', '%20') url = reverse('oidc_provider:authorize') + '?' + query_str @@ -140,6 +143,8 @@ class AuthorizationCodeFlowTestCase(TestCase): 'client_id': self.client.client_id, 'redirect_uri': self.client.default_redirect_uri, 'response_type': 'code', + 'code_challenge': FAKE_CODE_CHALLENGE, + 'code_challenge_method': 'S256', } for key, value in iter(to_check.items()): @@ -169,6 +174,9 @@ class AuthorizationCodeFlowTestCase(TestCase): 'response_type': response_type, 'scope': 'openid email', 'state': self.state, + # PKCE parameters. + 'code_challenge': FAKE_CODE_CHALLENGE, + 'code_challenge_method': 'S256', } request = self.factory.post(url, data=post_data) diff --git a/oidc_provider/views.py b/oidc_provider/views.py index 01f5d1b..c7010bb 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -87,7 +87,6 @@ class AuthorizeView(View): return redirect(uri) def post(self, request, *args, **kwargs): - authorize = AuthorizeEndpoint(request) allow = True if request.POST.get('allow') else False From e495d6c41da361d9768c3d705d8fd40ce25ff0d0 Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Thu, 7 Apr 2016 16:18:47 -0300 Subject: [PATCH 09/23] Remplace AES encryption with database. For saving PKCE parameters. --- oidc_provider/lib/endpoints/token.py | 18 ++++++------- oidc_provider/lib/utils/token.py | 18 +++++-------- .../migrations/0013_auto_20160407_1912.py | 25 +++++++++++++++++++ oidc_provider/models.py | 2 ++ oidc_provider/tests/app/utils.py | 3 ++- oidc_provider/tests/test_token_endpoint.py | 20 +++++++++++++++ 6 files changed, 63 insertions(+), 23 deletions(-) create mode 100644 oidc_provider/migrations/0013_auto_20160407_1912.py diff --git a/oidc_provider/lib/endpoints/token.py b/oidc_provider/lib/endpoints/token.py index 3c703c9..636a9d1 100644 --- a/oidc_provider/lib/endpoints/token.py +++ b/oidc_provider/lib/endpoints/token.py @@ -1,4 +1,4 @@ -from base64 import b64decode, urlsafe_b64encode +from base64 import b64decode, urlsafe_b64decode, urlsafe_b64encode import hashlib import logging import re @@ -73,10 +73,11 @@ class TokenEndpoint(object): logger.debug('[Token] Client does not exist: %s', self.params.client_id) raise TokenError('invalid_client') - if not (self.client.client_secret == self.params.client_secret): - logger.debug('[Token] Invalid client secret: client %s do not have secret %s', - self.client.client_id, self.client.client_secret) - raise TokenError('invalid_client') + if self.client.client_type == 'confidential': + if not (self.client.client_secret == self.params.client_secret): + logger.debug('[Token] Invalid client secret: client %s do not have secret %s', + self.client.client_id, self.client.client_secret) + raise TokenError('invalid_client') if self.params.grant_type == 'authorization_code': if not (self.params.redirect_uri in self.client.redirect_uris): @@ -97,16 +98,13 @@ class TokenEndpoint(object): # Validate PKCE parameters. if self.params.code_verifier: - obj = AES.new(hashlib.md5(django_settings.SECRET_KEY).hexdigest(), AES.MODE_CBC) - code_challenge, code_challenge_method = tuple(obj.decrypt(self.code.code.decode('hex')).split(':')) - - if code_challenge_method == 'S256': + if self.code.code_challenge_method == 'S256': new_code_challenge = urlsafe_b64encode(hashlib.sha256(self.params.code_verifier.encode('ascii')).digest()).replace('=', '') else: new_code_challenge = self.params.code_verifier # TODO: We should explain the error. - if not (new_code_challenge == code_challenge): + if not (new_code_challenge == self.code.code_challenge): raise TokenError('invalid_grant') elif self.params.grant_type == 'refresh_token': diff --git a/oidc_provider/lib/utils/token.py b/oidc_provider/lib/utils/token.py index b7260a3..8317ecf 100644 --- a/oidc_provider/lib/utils/token.py +++ b/oidc_provider/lib/utils/token.py @@ -1,10 +1,9 @@ +from base64 import urlsafe_b64decode, urlsafe_b64encode from datetime import timedelta import time import uuid -from Crypto.Cipher import AES from Crypto.PublicKey.RSA import importKey -from django.conf import settings as django_settings from django.utils import timezone from hashlib import md5 from jwkest.jwk import RSAKey as jwk_RSAKey @@ -108,16 +107,11 @@ def create_code(user, client, scope, nonce, is_authentication, code.user = user code.client = client - if not code_challenge: - code.code = uuid.uuid4().hex - else: - obj = AES.new(md5(django_settings.SECRET_KEY).hexdigest(), AES.MODE_CBC) - - # Default is 'plain' method. - code_challenge_method = 'plain' if not code_challenge_method else code_challenge_method - - ciphertext = obj.encrypt(code_challenge + ':' + code_challenge_method) - code.code = ciphertext.encode('hex') + code.code = uuid.uuid4().hex + + if code_challenge and code_challenge_method: + code.code_challenge = code_challenge + code.code_challenge_method = code_challenge_method code.expires_at = timezone.now() + timedelta( seconds=settings.get('OIDC_CODE_EXPIRE')) diff --git a/oidc_provider/migrations/0013_auto_20160407_1912.py b/oidc_provider/migrations/0013_auto_20160407_1912.py new file mode 100644 index 0000000..19cb444 --- /dev/null +++ b/oidc_provider/migrations/0013_auto_20160407_1912.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2016-04-07 19:12 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oidc_provider', '0012_auto_20160405_2041'), + ] + + operations = [ + migrations.AddField( + model_name='code', + name='code_challenge', + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name='code', + name='code_challenge_method', + field=models.CharField(max_length=255, null=True), + ), + ] diff --git a/oidc_provider/models.py b/oidc_provider/models.py index 8d9ad39..2944231 100644 --- a/oidc_provider/models.py +++ b/oidc_provider/models.py @@ -86,6 +86,8 @@ class Code(BaseCodeTokenModel): code = models.CharField(max_length=255, unique=True) nonce = models.CharField(max_length=255, blank=True, default='') is_authentication = models.BooleanField(default=False) + code_challenge = models.CharField(max_length=255, null=True) + code_challenge_method = models.CharField(max_length=255, null=True) class Meta: verbose_name = _(u'Authorization Code') diff --git a/oidc_provider/tests/app/utils.py b/oidc_provider/tests/app/utils.py index 5164f4f..99f9874 100644 --- a/oidc_provider/tests/app/utils.py +++ b/oidc_provider/tests/app/utils.py @@ -13,7 +13,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_CODE_CHALLENGE = 'pG6flQqJa7INfIKb5cZVAXhTqvTKehIck6aQhdUuyWc' +FAKE_CODE_CHALLENGE = 'YlYXEqXuRm-Xgi2BOUiK50JW1KsGTX6F1TDnZSC8VTg' +FAKE_CODE_VERIFIER = 'SmxGa0XueyNh5bDgTcSrqzAh2_FmXEqU8kDT6CuXicw' def create_fake_user(): diff --git a/oidc_provider/tests/test_token_endpoint.py b/oidc_provider/tests/test_token_endpoint.py index b17408d..31a2de7 100644 --- a/oidc_provider/tests/test_token_endpoint.py +++ b/oidc_provider/tests/test_token_endpoint.py @@ -445,3 +445,23 @@ class TokenTestCase(TestCase): self.assertEqual(id_token.get('test_idtoken_processing_hook2'), FAKE_RANDOM_STRING) self.assertEqual(id_token.get('test_idtoken_processing_hook_user_email2'), self.user.email) + + def test_pkce_parameters(self): + """ + Test Proof Key for Code Exchange by OAuth Public Clients. + https://tools.ietf.org/html/rfc7636 + """ + import pdb;pdb.set_trace() + code = create_code(user=self.user, client=self.client, + scope=['openid', 'email'], nonce=FAKE_NONCE, is_authentication=True, + code_challenge=FAKE_CODE_CHALLENGE, code_challenge_method='S256') + code.save() + + post_data = self._auth_code_post_data(code=code.code) + + # Add parameters. + post_data['code_verifier'] = FAKE_CODE_VERIFIER + + response = self._post_request(post_data) + + response_dic = json.loads(response.content.decode('utf-8')) From 559f90c5a6578c521cf0464f829db6f1d4ae8b6a Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Thu, 7 Apr 2016 16:36:42 -0300 Subject: [PATCH 10/23] Remove pdb. --- oidc_provider/tests/test_token_endpoint.py | 1 - 1 file changed, 1 deletion(-) diff --git a/oidc_provider/tests/test_token_endpoint.py b/oidc_provider/tests/test_token_endpoint.py index 31a2de7..652d8ad 100644 --- a/oidc_provider/tests/test_token_endpoint.py +++ b/oidc_provider/tests/test_token_endpoint.py @@ -451,7 +451,6 @@ class TokenTestCase(TestCase): Test Proof Key for Code Exchange by OAuth Public Clients. https://tools.ietf.org/html/rfc7636 """ - import pdb;pdb.set_trace() code = create_code(user=self.user, client=self.client, scope=['openid', 'email'], nonce=FAKE_NONCE, is_authentication=True, code_challenge=FAKE_CODE_CHALLENGE, code_challenge_method='S256') From 9adfa7e6bc5f10ed9c8c76a7685f6e8f013b2215 Mon Sep 17 00:00:00 2001 From: Ignacio Date: Thu, 7 Apr 2016 19:41:50 -0300 Subject: [PATCH 11/23] Edit templates in example project. --- example_project/provider_app/settings.py | 2 - .../provider_app/templates/base.html | 4 +- .../provider_app/templates/login.html | 42 ++++++++++--------- example_project/provider_app/urls.py | 2 + 4 files changed, 26 insertions(+), 24 deletions(-) diff --git a/example_project/provider_app/settings.py b/example_project/provider_app/settings.py index ec0c26f..d2a28af 100644 --- a/example_project/provider_app/settings.py +++ b/example_project/provider_app/settings.py @@ -87,5 +87,3 @@ LOGIN_REDIRECT_URL = '/' # OIDC Provider settings SITE_URL = 'http://localhost:8000' - -LOGIN_URL = '/admin/login/' diff --git a/example_project/provider_app/templates/base.html b/example_project/provider_app/templates/base.html index 92ef156..2bc7ab3 100644 --- a/example_project/provider_app/templates/base.html +++ b/example_project/provider_app/templates/base.html @@ -22,9 +22,9 @@ {% if user.is_superuser %} Admin {% endif %} - + {% else %} - Login + Login {% endif %}
diff --git a/example_project/provider_app/templates/login.html b/example_project/provider_app/templates/login.html index 8f3b7a9..ce89c09 100644 --- a/example_project/provider_app/templates/login.html +++ b/example_project/provider_app/templates/login.html @@ -2,27 +2,29 @@ {% block content %} -
-
-
- {% if form.errors %} -
-

Your username and password didn't match. Please try again.

+
+
+
+
+ {% if form.errors %} +
+

Your username and password didn't match. Please try again.

+
+ {% endif %} +
+ {% csrf_token %} + +
+ + +
+
+ + +
+ +
- {% endif %} -
- {% csrf_token %} - -
- - -
-
- - -
- -
diff --git a/example_project/provider_app/urls.py b/example_project/provider_app/urls.py index 60d246c..12e6abf 100644 --- a/example_project/provider_app/urls.py +++ b/example_project/provider_app/urls.py @@ -6,6 +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'^', include('oidc_provider.urls', namespace='oidc_provider')), From ed26f7bb40b555e516f0307b2d318a069b17c938 Mon Sep 17 00:00:00 2001 From: Ignacio Date: Thu, 7 Apr 2016 19:45:12 -0300 Subject: [PATCH 12/23] Edit gitignore. --- example_project/.gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example_project/.gitignore b/example_project/.gitignore index 6ddd3e2..9dd04fe 100644 --- a/example_project/.gitignore +++ b/example_project/.gitignore @@ -1,3 +1,3 @@ *.sqlite3 *.pem - +static/ From e97c32acd1b081c59c9be85db4f45f0ebe949f01 Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Fri, 8 Apr 2016 13:22:05 -0300 Subject: [PATCH 13/23] Fix encoding problem when using Py34. --- oidc_provider/lib/endpoints/token.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/oidc_provider/lib/endpoints/token.py b/oidc_provider/lib/endpoints/token.py index 636a9d1..2a687f5 100644 --- a/oidc_provider/lib/endpoints/token.py +++ b/oidc_provider/lib/endpoints/token.py @@ -99,7 +99,9 @@ class TokenEndpoint(object): # Validate PKCE parameters. if self.params.code_verifier: if self.code.code_challenge_method == 'S256': - new_code_challenge = urlsafe_b64encode(hashlib.sha256(self.params.code_verifier.encode('ascii')).digest()).replace('=', '') + new_code_challenge = urlsafe_b64encode( + hashlib.sha256(self.params.code_verifier.encode('ascii')).digest() + ).decode('utf-8').replace('=', '') else: new_code_challenge = self.params.code_verifier From c39a81e5f9acbb07cdb7036e999e076b4f747077 Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Fri, 8 Apr 2016 16:56:20 -0300 Subject: [PATCH 14/23] Update docs. --- docs/conf.py | 4 +-- docs/images/client_creation.png | Bin 0 -> 51150 bytes docs/index.rst | 7 ++++- docs/sections/clients.rst | 24 ----------------- docs/sections/relyingparties.rst | 43 +++++++++++++++++++++++++++++++ 5 files changed, 51 insertions(+), 27 deletions(-) create mode 100644 docs/images/client_creation.png delete mode 100644 docs/sections/clients.rst create mode 100644 docs/sections/relyingparties.rst diff --git a/docs/conf.py b/docs/conf.py index cf7b163..bb4f14f 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.2' +version = u'0.3' # The full version, including alpha/beta/rc tags. -release = u'0.2.5' +release = u'0.3.x' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/images/client_creation.png b/docs/images/client_creation.png new file mode 100644 index 0000000000000000000000000000000000000000..fac2105cbe1e94b73a3117d4899a1ab0bc453808 GIT binary patch literal 51150 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 LfJ$Gy{qR2kK_qV5 literal 0 HcmV?d00001 diff --git a/docs/index.rst b/docs/index.rst index 466355f..f33b663 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -3,6 +3,11 @@ Welcome to Django OIDC Provider Documentation! 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. And as a side effect a fair implementation of OAuth2.0 too. +Also implements the following specifications: + +* `OAuth 2.0 for Native Apps `_ +* `Proof Key for Code Exchange by OAuth Public Clients `_ + -------------------------------------------------------------------------------- Before getting started there are some important things that you should know: @@ -19,7 +24,7 @@ Contents: :maxdepth: 2 sections/installation - sections/clients + sections/relyingparties sections/serverkeys sections/templates sections/claims diff --git a/docs/sections/clients.rst b/docs/sections/clients.rst deleted file mode 100644 index e3f5ab8..0000000 --- a/docs/sections/clients.rst +++ /dev/null @@ -1,24 +0,0 @@ -.. _clients: - -Clients -####### - -Also known as Relying Parties (RP). User and client 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. By displaying a HTML form or maybe if you have internal thrusted Clients you can create them programatically. - -`Read more about client creation from OAuth2 spec `_ - -For your users, the tipical situation is that you provide them a login and a registration page. - -If you want to test the provider without getting to deep into this topics you can: - -Create a user with ``python manage.py createsuperuser`` and clients using Django admin: - -.. image:: http://i64.tinypic.com/2dsfgoy.png - :align: center - -Or also you can create a client programmatically with Django shell ``python manage.py shell``:: - - >>> from oidc_provider.models import Client - >>> c = Client(name='Some Client', client_id='123', client_secret='456', response_type='code', redirect_uris=['http://example.com/']) - >>> c.save() diff --git a/docs/sections/relyingparties.rst b/docs/sections/relyingparties.rst new file mode 100644 index 0000000..1c6d80c --- /dev/null +++ b/docs/sections/relyingparties.rst @@ -0,0 +1,43 @@ +.. _relyingparties: + +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. + +OAuth defines two client types, based on their ability to maintain the confidentiality of their client credentials: + +* ``confidential``: Clients capable of maintaining the confidentiality of their credentials (e.g., client implemented on a secure server with restricted access to the client credentials). +* ``public``: Clients incapable of maintaining the confidentiality of their credentials (e.g., clients executing on the device used by the resource owner, such as an installed native application or a web browser-based application), and incapable of secure client authentication via any other means. + +Using the admin +=============== + +We suggest you to use Django admin to easily manage your clients: + +.. image:: ../images/client_creation.png + :align: center + +For re-generating ``client_secret``, when you are in the Client editing view, select "Client type" to be ``public``. Then after saving, select back to be ``confidential`` and save again. + +Custom view +=========== + +If for some reason you need to create your own view to manage them, you can grab the form class that the admin makes use of. Located in ``oidc_provider.admin.ClientForm``. + +Some built-in logic that comes with it: + +* Automatic ``client_id`` and ``client_secret`` generation. +* Empty ``client_secret`` when ``client_type`` is equal to ``public``. + +Programmatically +================ + +You can create a Client programmatically with Django shell ``python manage.py shell``:: + + >>> from oidc_provider.models import Client + >>> 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 `_ From 3f5992100a4721ff6114b667f290339b4d6c3c5f Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Fri, 8 Apr 2016 18:09:24 -0300 Subject: [PATCH 15/23] Not auto-approve requests for non-confidential clients. --- oidc_provider/tests/app/utils.py | 11 ++++++--- .../tests/test_authorize_endpoint.py | 24 +++++++++++++++++++ oidc_provider/views.py | 4 ++-- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/oidc_provider/tests/app/utils.py b/oidc_provider/tests/app/utils.py index 99f9874..cd547d1 100644 --- a/oidc_provider/tests/app/utils.py +++ b/oidc_provider/tests/app/utils.py @@ -33,7 +33,7 @@ def create_fake_user(): return user -def create_fake_client(response_type): +def create_fake_client(response_type, is_public=False): """ Create a test client, response_type argument MUST be: 'code', 'id_token' or 'id_token token'. @@ -42,8 +42,13 @@ def create_fake_client(response_type): """ client = Client() client.name = 'Some Client' - client.client_id = '123' - client.client_secret = '456' + if is_public: + client.client_type = 'public' + client.client_id = 'p123' + client.client_secret = '' + else: + client.client_id = 'c123' + client.client_secret = '456' client.response_type = response_type client.redirect_uris = ['http://example.com/'] diff --git a/oidc_provider/tests/test_authorize_endpoint.py b/oidc_provider/tests/test_authorize_endpoint.py index 5a3eaa2..9b24d95 100644 --- a/oidc_provider/tests/test_authorize_endpoint.py +++ b/oidc_provider/tests/test_authorize_endpoint.py @@ -25,6 +25,7 @@ class AuthorizationCodeFlowTestCase(TestCase): self.factory = RequestFactory() self.user = create_fake_user() self.client = create_fake_client(response_type='code') + self.client_public = create_fake_client(response_type='code', is_public=True) self.state = uuid.uuid4().hex def test_missing_parameters(self): @@ -310,8 +311,31 @@ class AuthorizationCodeFlowTestCase(TestCase): response = AuthorizeView.as_view()(request) + # Search the scopes in the html. self.assertEqual(scope_test in response.content.decode('utf-8'), True) + def test_public_client_auto_approval(self): + """ + It's recommended not auto-approving requests for non-confidential clients. + """ + query_str = urlencode({ + 'client_id': self.client_public.client_id, + 'response_type': 'code', + 'redirect_uri': self.client_public.default_redirect_uri, + 'scope': 'openid email', + 'state': self.state, + }) + + url = reverse('oidc_provider:authorize') + '?' + query_str + + request = self.factory.get(url) + # Simulate that the user is logged. + request.user = self.user + + with self.settings(OIDC_SKIP_CONSENT_ALWAYS=True): + response = AuthorizeView.as_view()(request) + + self.assertEqual('Request for Permission' in response.content.decode('utf-8'), True) class ImplicitFlowTestCase(TestCase): """ diff --git a/oidc_provider/views.py b/oidc_provider/views.py index c7010bb..2af287d 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -40,12 +40,12 @@ class AuthorizeView(View): if hook_resp: return hook_resp - if settings.get('OIDC_SKIP_CONSENT_ALWAYS'): + if settings.get('OIDC_SKIP_CONSENT_ALWAYS') and not (authorize.client.client_type == 'public'): return redirect(authorize.create_response_uri()) if settings.get('OIDC_SKIP_CONSENT_ENABLE'): # Check if user previously give consent. - if authorize.client_has_user_consent(): + if authorize.client_has_user_consent() and not (authorize.client.client_type == 'public'): return redirect(authorize.create_response_uri()) # Generate hidden inputs for the form. From 32950bc65561b4fcecb95297aebb9a62800833bd Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Fri, 8 Apr 2016 18:11:51 -0300 Subject: [PATCH 16/23] Edit CHANGELOG. --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f865fa5..bb60ca8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ All notable changes to this project will be documented in this file. - Choose type of client on creation. - Implement Proof Key for Code Exchange by OAuth Public Clients. +##### Fixed +- Not auto-approve requests for non-confidential clients (publics). + ### [0.3.1] - 2016-03-09 ##### Fixed From b05894bf6d6f7f418b2e5363ca5203e9d911aea9 Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Tue, 12 Apr 2016 18:19:16 -0300 Subject: [PATCH 17/23] Add prompt parameter to authorize view. --- oidc_provider/lib/endpoints/authorize.py | 3 ++- oidc_provider/views.py | 7 +++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/oidc_provider/lib/endpoints/authorize.py b/oidc_provider/lib/endpoints/authorize.py index 3bb6409..83624ad 100644 --- a/oidc_provider/lib/endpoints/authorize.py +++ b/oidc_provider/lib/endpoints/authorize.py @@ -55,6 +55,7 @@ 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', '') # PKCE parameters. self.params.code_challenge = query_dict.get('code_challenge') @@ -91,7 +92,7 @@ class AuthorizeEndpoint(object): raise RedirectUriError() # PKCE validation of the transformation method. - if self.params.code_challenge and self.params.code_challenge_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) diff --git a/oidc_provider/views.py b/oidc_provider/views.py index 2af287d..bd5a6f8 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -68,8 +68,11 @@ class AuthorizeView(View): return render(request, 'oidc_provider/authorize.html', context) else: - path = request.get_full_path() - return redirect_to_login(path) + if authorize.params.prompt == 'none': + raise AuthorizeError(authorize.params.redirect_uri, 'login_required', authorize.grant_type) + else: + path = request.get_full_path() + return redirect_to_login(path) except (ClientIdError, RedirectUriError) as error: context = { From 31632442bda97bd25fa112d1bfcac12f4779ffe2 Mon Sep 17 00:00:00 2001 From: John Kristensen Date: Wed, 13 Apr 2016 17:02:05 +1000 Subject: [PATCH 18/23] Add py35 environment to tox/travisci Django v1.7 is not compatible with Python v3.5 so it is not tested. --- .travis.yml | 5 +++++ tox.ini | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 56c5e66..d947e99 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,10 +2,15 @@ language: python python: - "2.7" - "3.4" + - "3.5" env: - DJANGO=1.7 - DJANGO=1.8 - DJANGO=1.9 +matrix: + exclude: + - python: "3.5" + env: DJANGO=1.7 install: - pip install -q django==$DJANGO - pip install -e . diff --git a/tox.ini b/tox.ini index 92540ff..2b107d0 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] envlist= - clean,py{27,34}-django{17,18,19},stats + clean,py{27,34}-django{17,18,19},py35-django{18,19},stats [testenv] From 61f0c209af7a4e4f229029c66cdadba4d2ae336b Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Wed, 13 Apr 2016 17:19:37 -0300 Subject: [PATCH 19/23] Refactoring prompt=none logic. --- oidc_provider/lib/endpoints/authorize.py | 32 +++++++++++++----------- oidc_provider/views.py | 12 ++++----- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/oidc_provider/lib/endpoints/authorize.py b/oidc_provider/lib/endpoints/authorize.py index 83624ad..dcdb8da 100644 --- a/oidc_provider/lib/endpoints/authorize.py +++ b/oidc_provider/lib/endpoints/authorize.py @@ -62,35 +62,39 @@ class AuthorizeEndpoint(object): self.params.code_challenge_method = query_dict.get('code_challenge_method') def validate_params(self): + # Client validation. try: self.client = Client.objects.get(client_id=self.params.client_id) except Client.DoesNotExist: logger.debug('[Authorize] Invalid client identifier: %s', self.params.client_id) raise ClientIdError() + # Redirect URI validation. if self.is_authentication and not self.params.redirect_uri: logger.debug('[Authorize] Missing redirect uri.') raise RedirectUriError() - - 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) - - if self.is_authentication and self.grant_type == 'implicit' and not self.params.nonce: - raise AuthorizeError(self.params.redirect_uri, 'invalid_request', - self.grant_type) - - if self.is_authentication and self.params.response_type != self.client.response_type: - raise AuthorizeError(self.params.redirect_uri, 'invalid_request', - self.grant_type) - clean_redirect_uri = urlsplit(self.params.redirect_uri) clean_redirect_uri = urlunsplit(clean_redirect_uri._replace(query='')) if not (clean_redirect_uri in self.client.redirect_uris): logger.debug('[Authorize] Invalid redirect uri: %s', self.params.redirect_uri) raise RedirectUriError() + # 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) + + # Nonce parameter validation. + if self.is_authentication and self.grant_type == 'implicit' and not self.params.nonce: + raise AuthorizeError(self.params.redirect_uri, 'invalid_request', + self.grant_type) + + # Response type parameter validation. + if self.is_authentication and self.params.response_type != self.client.response_type: + raise AuthorizeError(self.params.redirect_uri, 'invalid_request', + self.grant_type) + # PKCE validation of the transformation method. if self.params.code_challenge: if not (self.params.code_challenge_method in ['plain', 'S256']): diff --git a/oidc_provider/views.py b/oidc_provider/views.py index bd5a6f8..016bddd 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -66,13 +66,15 @@ class AuthorizeView(View): 'params': authorize.params, } + if authorize.params.prompt == 'none': + raise AuthorizeError(authorize.params.redirect_uri, 'interaction_required', authorize.grant_type) + return render(request, 'oidc_provider/authorize.html', context) else: if authorize.params.prompt == 'none': raise AuthorizeError(authorize.params.redirect_uri, 'login_required', authorize.grant_type) - else: - path = request.get_full_path() - return redirect_to_login(path) + + return redirect_to_login(request.get_full_path()) except (ClientIdError, RedirectUriError) as error: context = { @@ -92,12 +94,10 @@ class AuthorizeView(View): def post(self, request, *args, **kwargs): authorize = AuthorizeEndpoint(request) - allow = True if request.POST.get('allow') else False - try: authorize.validate_params() - if not allow: + if not request.POST.get('allow'): raise AuthorizeError(authorize.params.redirect_uri, 'access_denied', authorize.grant_type) From 41dcb192bcbd1718b66f84b201f7c234c14a81bf Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Wed, 13 Apr 2016 18:38:38 -0300 Subject: [PATCH 20/23] Add support for the other values of the prompt param. --- oidc_provider/views.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/oidc_provider/views.py b/oidc_provider/views.py index 016bddd..9ed42c9 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -40,12 +40,14 @@ class AuthorizeView(View): if hook_resp: return hook_resp - if settings.get('OIDC_SKIP_CONSENT_ALWAYS') and not (authorize.client.client_type == 'public'): + if settings.get('OIDC_SKIP_CONSENT_ALWAYS') and not (authorize.client.client_type == 'public') \ + and not (authorize.params.prompt == 'consent'): return redirect(authorize.create_response_uri()) if settings.get('OIDC_SKIP_CONSENT_ENABLE'): # Check if user previously give consent. - if authorize.client_has_user_consent() and not (authorize.client.client_type == 'public'): + if authorize.client_has_user_consent() and not (authorize.client.client_type == 'public') \ + and not (authorize.params.prompt == 'consent'): return redirect(authorize.create_response_uri()) # Generate hidden inputs for the form. @@ -69,6 +71,13 @@ class AuthorizeView(View): if authorize.params.prompt == 'none': raise AuthorizeError(authorize.params.redirect_uri, 'interaction_required', authorize.grant_type) + if authorize.params.prompt == 'login': + return redirect_to_login(request.get_full_path()) + + 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) + return render(request, 'oidc_provider/authorize.html', context) else: if authorize.params.prompt == 'none': From bc6a0835714e6475dc62a876aff2baaeb503112a Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Thu, 14 Apr 2016 16:22:38 -0300 Subject: [PATCH 21/23] Refactoring tests. --- oidc_provider/tests/app/RSAKEY.pem | 15 -- oidc_provider/tests/app/utils.py | 16 +- .../tests/test_authorize_endpoint.py | 221 ++++++------------ oidc_provider/tests/test_token_endpoint.py | 3 +- 4 files changed, 69 insertions(+), 186 deletions(-) delete mode 100644 oidc_provider/tests/app/RSAKEY.pem diff --git a/oidc_provider/tests/app/RSAKEY.pem b/oidc_provider/tests/app/RSAKEY.pem deleted file mode 100644 index bcad3a0..0000000 --- a/oidc_provider/tests/app/RSAKEY.pem +++ /dev/null @@ -1,15 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIICXgIBAAKBgQC/O5N0BxpMVbht7i0bFIQyD0q2O4mutyYLoAQn8skYEbDUmcwp -9dRe7GTHiDrMqJ3gW9hTZcYm7dt5rhjFqdCYK504PDOcK8LGkCN2CiWeRbCAwaz0 -Wgh3oJfbTMuYV+LWLFAAPxN4cyN6RoE9mlk7vq7YNYVpdg0VNMAKvW95dQIDAQAB -AoGBAIBMdxw0G7e1Fxxh3E87z4lKaySiAzh91f+cps0qfTIxxEKOwMQyEv5weRjJ -VDG0ut8on5UsReoeUM5tOF99E92pEnenI7+VfnFf04xCLcdT0XGbKimb+5g6y1Pm -8630TD97tVO0ASHcrXOtkSTYNdAUDcqeJUTOwgW0OD3Hyb8BAkEAxODr/Mln86wu -NhnxEVf9wuEJxX6JUjnkh62wIWYbZU61D+pIrtofi/0+AYn/9IeBCTDNIM4qTzsC -HV/u/3nmwQJBAPiooD4FYBI1VOwZ7RZqR0ZyQN0IkBsfw95K789I1lBeXh34b6r6 -dik4A72guaAZEuxTz3MPjbSrflGjq47fE7UCQQCPsDSrpvcGYbjMZXyKkvSywXlX -OXXRnE0NNReiGJqQArSk6/GmI634hpg1mVlER41GfuaHNdCtSLzPYY/Vx0tBAkAc -QFxkb4voxbJuWMu9HjoW4OhJtK1ax5MjcHQqouXmn7IlyZI2ZNqD+F9Ebjxo2jBy -NVt+gSfifRGPCP927hV5AkEAwFu9HZipddp8PM8tyF1G09+s3DVSCR3DLMBwX9NX -nGA9tOLYOSgG/HKLOWD1qT0G8r/vYtFuktCKMSidVMp5sw== ------END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/oidc_provider/tests/app/utils.py b/oidc_provider/tests/app/utils.py index cd547d1..684dac8 100644 --- a/oidc_provider/tests/app/utils.py +++ b/oidc_provider/tests/app/utils.py @@ -42,13 +42,12 @@ def create_fake_client(response_type, is_public=False): """ client = Client() client.name = 'Some Client' + client.client_id = str(random.randint(1, 999999)).zfill(6) if is_public: client.client_type = 'public' - client.client_id = 'p123' client.client_secret = '' else: - client.client_id = 'c123' - client.client_secret = '456' + client.client_secret = str(random.randint(1, 999999)).zfill(6) client.response_type = response_type client.redirect_uris = ['http://example.com/'] @@ -57,17 +56,6 @@ def create_fake_client(response_type, is_public=False): return client -def create_rsakey(): - """ - Generate and save a sample RSA Key. - """ - fullpath = os.path.abspath(os.path.dirname(__file__)) + '/RSAKEY.pem' - - with open(fullpath, 'r') as f: - key = f.read() - RSAKey(key=key).save() - - def is_code_valid(url, user, client): """ Check if the code inside the url is valid. diff --git a/oidc_provider/tests/test_authorize_endpoint.py b/oidc_provider/tests/test_authorize_endpoint.py index 9b24d95..9a26761 100644 --- a/oidc_provider/tests/test_authorize_endpoint.py +++ b/oidc_provider/tests/test_authorize_endpoint.py @@ -6,6 +6,7 @@ import uuid from django.contrib.auth import REDIRECT_FIELD_NAME from django.contrib.auth.models import AnonymousUser +from django.core.management import call_command from django.core.urlresolvers import reverse from django.test import RequestFactory from django.test import TestCase @@ -22,11 +23,35 @@ class AuthorizationCodeFlowTestCase(TestCase): """ def setUp(self): + call_command('creatersakey') self.factory = RequestFactory() self.user = create_fake_user() self.client = create_fake_client(response_type='code') self.client_public = create_fake_client(response_type='code', is_public=True) + self.client_implicit = create_fake_client(response_type='id_token token') self.state = uuid.uuid4().hex + self.nonce = uuid.uuid4().hex + + def _auth_request(self, method, params_or_data={}, is_user_authenticated=False): + url = reverse('oidc_provider:authorize') + + if method.lower() == 'get': + query_str = urlencode(params_or_data).replace('+', '%20') + if query_str: + url += '?' + query_str + request = self.factory.get(url) + elif method.lower() == 'post': + request = self.factory.post(url, data=params_or_data) + else: + raise Exception('Method unsupported for an Authorization Request.') + + # Simulate that the user is logged. + request.user = self.user if is_user_authenticated else AnonymousUser() + + response = AuthorizeView.as_view()(request) + + return response + def test_missing_parameters(self): """ @@ -36,11 +61,7 @@ class AuthorizationCodeFlowTestCase(TestCase): See: https://tools.ietf.org/html/rfc6749#section-4.1.2.1 """ - url = reverse('oidc_provider:authorize') - - request = self.factory.get(url) - - response = AuthorizeView.as_view()(request) + response = self._auth_request('get') self.assertEqual(response.status_code, 200) self.assertEqual(bool(response.content), True) @@ -53,19 +74,15 @@ class AuthorizationCodeFlowTestCase(TestCase): See: http://openid.net/specs/openid-connect-core-1_0.html#AuthError """ # Create an authorize request with an unsupported response_type. - query_str = urlencode({ + params = { 'client_id': self.client.client_id, 'response_type': 'something_wrong', 'redirect_uri': self.client.default_redirect_uri, 'scope': 'openid email', 'state': self.state, - }).replace('+', '%20') + } - url = reverse('oidc_provider:authorize') + '?' + query_str - - request = self.factory.get(url) - - response = AuthorizeView.as_view()(request) + response = self._auth_request('get', params) self.assertEqual(response.status_code, 302) self.assertEqual(response.has_header('Location'), True) @@ -81,34 +98,20 @@ class AuthorizationCodeFlowTestCase(TestCase): See: http://openid.net/specs/openid-connect-core-1_0.html#Authenticates """ - query_str = urlencode({ + params = { 'client_id': self.client.client_id, 'response_type': 'code', 'redirect_uri': self.client.default_redirect_uri, 'scope': 'openid email', 'state': self.state, - }).replace('+', '%20') + } - url = reverse('oidc_provider:authorize') + '?' + query_str - - request = self.factory.get(url) - request.user = AnonymousUser() - - response = AuthorizeView.as_view()(request) + response = self._auth_request('get', params) # Check if user was redirected to the login view. login_url_exists = settings.get('LOGIN_URL') in response['Location'] self.assertEqual(login_url_exists, True) - # Check if the login will redirect to a valid url. - try: - next_value = response['Location'].split(REDIRECT_FIELD_NAME + '=')[1] - next_url = unquote(next_value) - is_next_ok = next_url == url - except: - is_next_ok = False - self.assertEqual(is_next_ok, True) - def test_user_consent_inputs(self): """ Once the End-User is authenticated, the Authorization Server MUST @@ -117,7 +120,7 @@ class AuthorizationCodeFlowTestCase(TestCase): See: http://openid.net/specs/openid-connect-core-1_0.html#Consent """ - query_str = urlencode({ + params = { 'client_id': self.client.client_id, 'response_type': 'code', 'redirect_uri': self.client.default_redirect_uri, @@ -126,15 +129,9 @@ class AuthorizationCodeFlowTestCase(TestCase): # PKCE parameters. 'code_challenge': FAKE_CODE_CHALLENGE, 'code_challenge_method': 'S256', - }).replace('+', '%20') + } - url = reverse('oidc_provider:authorize') + '?' + query_str - - request = self.factory.get(url) - # Simulate that the user is logged. - request.user = self.user - - response = AuthorizeView.as_view()(request) + response = self._auth_request('get', params, is_user_authenticated=True) # Check if hidden inputs exists in the form, # also if their values are valid. @@ -165,14 +162,10 @@ class AuthorizationCodeFlowTestCase(TestCase): the parameters defined in Section 4.1.2 of OAuth 2.0 [RFC6749] by adding them as query parameters to the redirect_uri. """ - response_type = 'code' - - url = reverse('oidc_provider:authorize') - - post_data = { + data = { 'client_id': self.client.client_id, 'redirect_uri': self.client.default_redirect_uri, - 'response_type': response_type, + 'response_type': 'code', 'scope': 'openid email', 'state': self.state, # PKCE parameters. @@ -180,11 +173,7 @@ class AuthorizationCodeFlowTestCase(TestCase): 'code_challenge_method': 'S256', } - request = self.factory.post(url, data=post_data) - # Simulate that the user is logged. - request.user = self.user - - response = AuthorizeView.as_view()(request) + response = self._auth_request('post', data, is_user_authenticated=True) # Because user doesn't allow app, SHOULD exists an error parameter # in the query. @@ -194,13 +183,9 @@ class AuthorizationCodeFlowTestCase(TestCase): msg='"access_denied" code is missing in query.') # Simulate user authorization. - post_data['allow'] = 'Accept' # Should be the value of the button. + data['allow'] = 'Accept' # Will be the value of the button. - request = self.factory.post(url, data=post_data) - # Simulate that the user is logged. - request.user = self.user - - response = AuthorizeView.as_view()(request) + response = self._auth_request('post', data, is_user_authenticated=True) is_code_ok = is_code_valid(url=response['Location'], user=self.user, @@ -219,7 +204,7 @@ class AuthorizationCodeFlowTestCase(TestCase): list of scopes) and because they might be prompted for the same authorization multiple times, the server skip it. """ - post_data = { + data = { 'client_id': self.client.client_id, 'redirect_uri': self.client.default_redirect_uri, 'response_type': 'code', @@ -229,34 +214,25 @@ class AuthorizationCodeFlowTestCase(TestCase): } request = self.factory.post(reverse('oidc_provider:authorize'), - data=post_data) + data=data) # Simulate that the user is logged. request.user = self.user with self.settings(OIDC_SKIP_CONSENT_ALWAYS=True): - response = AuthorizeView.as_view()(request) + response = self._auth_request('post', data, is_user_authenticated=True) self.assertEqual('code' in response['Location'], True, msg='Code is missing in the returned url.') - response = AuthorizeView.as_view()(request) + response = self._auth_request('post', data, is_user_authenticated=True) 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.') - del post_data['allow'] - query_str = urlencode(post_data).replace('+', '%20') - - url = reverse('oidc_provider:authorize') + '?' + query_str - - request = self.factory.get(url) - # Simulate that the user is logged. - request.user = self.user - - # Ensure user consent skip is enabled. - response = AuthorizeView.as_view()(request) + del data['allow'] + response = self._auth_request('get', data, is_user_authenticated=True) is_code_ok = is_code_valid(url=response['Location'], user=self.user, @@ -264,10 +240,7 @@ class AuthorizationCodeFlowTestCase(TestCase): self.assertEqual(is_code_ok, True, msg='Code returned is invalid or missing.') def test_response_uri_is_properly_constructed(self): - """ - TODO - """ - post_data = { + data = { 'client_id': self.client.client_id, 'redirect_uri': self.client.default_redirect_uri + "?redirect_state=xyz", 'response_type': 'code', @@ -276,123 +249,59 @@ class AuthorizationCodeFlowTestCase(TestCase): 'allow': 'Accept', } - request = self.factory.post(reverse('oidc_provider:authorize'), - data=post_data) - # Simulate that the user is logged. - request.user = self.user + response = self._auth_request('post', data, is_user_authenticated=True) - response = AuthorizeView.as_view()(request) - - 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.') - - def test_scope_with_plus(self): - """ - In query string, scope use `+` instead of the space url-encoded. - """ - scope_test = 'openid email profile' - - query_str = urlencode({ - 'client_id': self.client.client_id, - 'response_type': 'code', - 'redirect_uri': self.client.default_redirect_uri, - 'scope': scope_test, - 'state': self.state, - }) - - url = reverse('oidc_provider:authorize') + '?' + query_str - - request = self.factory.get(url) - # Simulate that the user is logged. - request.user = self.user - - response = AuthorizeView.as_view()(request) - - # Search the scopes in the html. - self.assertEqual(scope_test in response.content.decode('utf-8'), True) + # TODO def test_public_client_auto_approval(self): """ It's recommended not auto-approving requests for non-confidential clients. """ - query_str = urlencode({ + params = { 'client_id': self.client_public.client_id, 'response_type': 'code', 'redirect_uri': self.client_public.default_redirect_uri, 'scope': 'openid email', 'state': self.state, - }) - - url = reverse('oidc_provider:authorize') + '?' + query_str - - request = self.factory.get(url) - # Simulate that the user is logged. - request.user = self.user + } with self.settings(OIDC_SKIP_CONSENT_ALWAYS=True): - response = AuthorizeView.as_view()(request) + response = self._auth_request('get', params, is_user_authenticated=True) self.assertEqual('Request for Permission' in response.content.decode('utf-8'), True) -class ImplicitFlowTestCase(TestCase): - """ - Test cases for Authorize Endpoint using Implicit Grant Flow. - """ - - def setUp(self): - self.factory = RequestFactory() - self.user = create_fake_user() - self.client = create_fake_client(response_type='id_token token') - self.state = uuid.uuid4().hex - self.nonce = uuid.uuid4().hex - create_rsakey() - - def test_missing_nonce(self): + def test_implicit_missing_nonce(self): """ The `nonce` parameter is REQUIRED if you use the Implicit Flow. """ - query_str = urlencode({ - 'client_id': self.client.client_id, - 'response_type': self.client.response_type, - 'redirect_uri': self.client.default_redirect_uri, + params = { + 'client_id': self.client_implicit.client_id, + 'response_type': self.client_implicit.response_type, + 'redirect_uri': self.client_implicit.default_redirect_uri, 'scope': 'openid email', 'state': self.state, - }).replace('+', '%20') + } - url = reverse('oidc_provider:authorize') + '?' + query_str - - request = self.factory.get(url) - # Simulate that the user is logged. - request.user = self.user - - response = AuthorizeView.as_view()(request) + response = self._auth_request('get', params, is_user_authenticated=True) self.assertEqual('#error=invalid_request' in response['Location'], True) - def test_access_token_response(self): + def test_implicit_access_token_response(self): """ Unlike the Authorization Code flow, in which the client makes separate requests for authorization and for an access token, the client receives the access token as the result of the authorization request. """ - post_data = { - 'client_id': self.client.client_id, - 'redirect_uri': self.client.default_redirect_uri, - 'response_type': self.client.response_type, + data = { + 'client_id': self.client_implicit.client_id, + 'redirect_uri': self.client_implicit.default_redirect_uri, + 'response_type': self.client_implicit.response_type, 'scope': 'openid email', 'state': self.state, 'nonce': self.nonce, 'allow': 'Accept', } - request = self.factory.post(reverse('oidc_provider:authorize'), - data=post_data) - # Simulate that the user is logged. - request.user = self.user - - response = AuthorizeView.as_view()(request) + response = self._auth_request('post', data, is_user_authenticated=True) self.assertEqual('access_token' in response['Location'], True) diff --git a/oidc_provider/tests/test_token_endpoint.py b/oidc_provider/tests/test_token_endpoint.py index 652d8ad..0873b23 100644 --- a/oidc_provider/tests/test_token_endpoint.py +++ b/oidc_provider/tests/test_token_endpoint.py @@ -4,6 +4,7 @@ try: except ImportError: from urllib import urlencode +from django.core.management import call_command from django.test import RequestFactory, override_settings from django.test import TestCase from jwkest.jwk import KEYS @@ -23,10 +24,10 @@ class TokenTestCase(TestCase): """ def setUp(self): + call_command('creatersakey') self.factory = RequestFactory() self.user = create_fake_user() self.client = create_fake_client(response_type='code') - create_rsakey() def _auth_code_post_data(self, code): """ From 8320394a674dcdc1093287d6bcd5ed8169d02c3f Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Thu, 14 Apr 2016 17:45:30 -0300 Subject: [PATCH 22/23] Refactoring variables. --- .../tests/test_authorize_endpoint.py | 54 ++++++++++++++----- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/oidc_provider/tests/test_authorize_endpoint.py b/oidc_provider/tests/test_authorize_endpoint.py index 9a26761..dd22800 100644 --- a/oidc_provider/tests/test_authorize_endpoint.py +++ b/oidc_provider/tests/test_authorize_endpoint.py @@ -32,16 +32,16 @@ class AuthorizationCodeFlowTestCase(TestCase): self.state = uuid.uuid4().hex self.nonce = uuid.uuid4().hex - def _auth_request(self, method, params_or_data={}, is_user_authenticated=False): + def _auth_request(self, method, data={}, is_user_authenticated=False): url = reverse('oidc_provider:authorize') if method.lower() == 'get': - query_str = urlencode(params_or_data).replace('+', '%20') + query_str = urlencode(data).replace('+', '%20') if query_str: url += '?' + query_str request = self.factory.get(url) elif method.lower() == 'post': - request = self.factory.post(url, data=params_or_data) + request = self.factory.post(url, data=data) else: raise Exception('Method unsupported for an Authorization Request.') @@ -74,7 +74,7 @@ class AuthorizationCodeFlowTestCase(TestCase): See: http://openid.net/specs/openid-connect-core-1_0.html#AuthError """ # Create an authorize request with an unsupported response_type. - params = { + data = { 'client_id': self.client.client_id, 'response_type': 'something_wrong', 'redirect_uri': self.client.default_redirect_uri, @@ -82,7 +82,7 @@ class AuthorizationCodeFlowTestCase(TestCase): 'state': self.state, } - response = self._auth_request('get', params) + response = self._auth_request('get', data) self.assertEqual(response.status_code, 302) self.assertEqual(response.has_header('Location'), True) @@ -98,7 +98,7 @@ class AuthorizationCodeFlowTestCase(TestCase): See: http://openid.net/specs/openid-connect-core-1_0.html#Authenticates """ - params = { + data = { 'client_id': self.client.client_id, 'response_type': 'code', 'redirect_uri': self.client.default_redirect_uri, @@ -106,7 +106,7 @@ class AuthorizationCodeFlowTestCase(TestCase): 'state': self.state, } - response = self._auth_request('get', params) + response = self._auth_request('get', data) # Check if user was redirected to the login view. login_url_exists = settings.get('LOGIN_URL') in response['Location'] @@ -120,7 +120,7 @@ class AuthorizationCodeFlowTestCase(TestCase): See: http://openid.net/specs/openid-connect-core-1_0.html#Consent """ - params = { + data = { 'client_id': self.client.client_id, 'response_type': 'code', 'redirect_uri': self.client.default_redirect_uri, @@ -131,7 +131,7 @@ class AuthorizationCodeFlowTestCase(TestCase): 'code_challenge_method': 'S256', } - response = self._auth_request('get', params, is_user_authenticated=True) + response = self._auth_request('get', data, is_user_authenticated=True) # Check if hidden inputs exists in the form, # also if their values are valid. @@ -257,7 +257,7 @@ class AuthorizationCodeFlowTestCase(TestCase): """ It's recommended not auto-approving requests for non-confidential clients. """ - params = { + data = { 'client_id': self.client_public.client_id, 'response_type': 'code', 'redirect_uri': self.client_public.default_redirect_uri, @@ -266,7 +266,7 @@ class AuthorizationCodeFlowTestCase(TestCase): } with self.settings(OIDC_SKIP_CONSENT_ALWAYS=True): - response = self._auth_request('get', params, is_user_authenticated=True) + response = self._auth_request('get', data, is_user_authenticated=True) self.assertEqual('Request for Permission' in response.content.decode('utf-8'), True) @@ -274,7 +274,7 @@ class AuthorizationCodeFlowTestCase(TestCase): """ The `nonce` parameter is REQUIRED if you use the Implicit Flow. """ - params = { + data = { 'client_id': self.client_implicit.client_id, 'response_type': self.client_implicit.response_type, 'redirect_uri': self.client_implicit.default_redirect_uri, @@ -282,7 +282,7 @@ class AuthorizationCodeFlowTestCase(TestCase): 'state': self.state, } - response = self._auth_request('get', params, is_user_authenticated=True) + response = self._auth_request('get', data, is_user_authenticated=True) self.assertEqual('#error=invalid_request' in response['Location'], True) @@ -304,4 +304,30 @@ class AuthorizationCodeFlowTestCase(TestCase): response = self._auth_request('post', data, is_user_authenticated=True) - self.assertEqual('access_token' in response['Location'], True) + self.assertEqual('access_token' in response['Location'], True) + + + def test_prompt_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, + } + + data['prompt'] = 'none' + + response = self._auth_request('get', data) + + # An error is returned if an End-User is not already authenticated. + self.assertEqual('login_required' in response['Location'], True) + + 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.assertEqual('interaction_required' in response['Location'], True) From 2ec000e18afbaf98c95d4222bb9442b8cc9e81b2 Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Fri, 15 Apr 2016 11:35:23 -0300 Subject: [PATCH 23/23] Edit CHANGELOG. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb60ca8..811ee36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file. ##### Added - Choose type of client on creation. - Implement Proof Key for Code Exchange by OAuth Public Clients. +- Support for prompt parameter. ##### Fixed - Not auto-approve requests for non-confidential clients (publics).