From dff76cd1ea1927fce40f4f3bb2367d3fcf2f7981 Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Tue, 22 Mar 2016 16:17:56 -0300 Subject: [PATCH 01/52] Add HS256 support for JWS. --- oidc_provider/lib/endpoints/authorize.py | 2 +- oidc_provider/lib/endpoints/token.py | 4 ++-- oidc_provider/lib/utils/token.py | 22 ++++++++++++------- .../migrations/0011_client_jwt_alg.py | 20 +++++++++++++++++ oidc_provider/models.py | 9 ++++++-- 5 files changed, 44 insertions(+), 13 deletions(-) create mode 100644 oidc_provider/migrations/0011_client_jwt_alg.py diff --git a/oidc_provider/lib/endpoints/authorize.py b/oidc_provider/lib/endpoints/authorize.py index 56972a4..f38c6ce 100644 --- a/oidc_provider/lib/endpoints/authorize.py +++ b/oidc_provider/lib/endpoints/authorize.py @@ -113,7 +113,7 @@ class AuthorizeEndpoint(object): user=self.request.user, aud=self.client.client_id, nonce=self.params.nonce) - query_fragment['id_token'] = encode_id_token(id_token_dic) + query_fragment['id_token'] = encode_id_token(id_token_dic, self.client) else: id_token_dic = {} diff --git a/oidc_provider/lib/endpoints/token.py b/oidc_provider/lib/endpoints/token.py index a981eee..099947b 100644 --- a/oidc_provider/lib/endpoints/token.py +++ b/oidc_provider/lib/endpoints/token.py @@ -140,7 +140,7 @@ class TokenEndpoint(object): 'refresh_token': token.refresh_token, 'token_type': 'bearer', 'expires_in': settings.get('OIDC_TOKEN_EXPIRE'), - 'id_token': encode_id_token(id_token_dic), + 'id_token': encode_id_token(id_token_dic, token.client), } return dic @@ -173,7 +173,7 @@ class TokenEndpoint(object): 'refresh_token': token.refresh_token, 'token_type': 'bearer', 'expires_in': settings.get('OIDC_TOKEN_EXPIRE'), - 'id_token': encode_id_token(id_token_dic), + 'id_token': encode_id_token(id_token_dic, self.token.client), } return dic diff --git a/oidc_provider/lib/utils/token.py b/oidc_provider/lib/utils/token.py index e512326..4c8ca93 100644 --- a/oidc_provider/lib/utils/token.py +++ b/oidc_provider/lib/utils/token.py @@ -6,6 +6,7 @@ from Crypto.PublicKey.RSA import importKey from django.utils import timezone from hashlib import md5 from jwkest.jwk import RSAKey as jwk_RSAKey +from jwkest.jwk import SYMKey from jwkest.jws import JWS from oidc_provider.lib.utils.common import get_issuer @@ -55,21 +56,26 @@ def create_id_token(user, aud, nonce): return dic -def encode_id_token(payload): +def encode_id_token(payload, client): """ Represent the ID Token as a JSON Web Token (JWT). Return a hash. """ - keys = [] + alg = client.jwt_alg + if alg == 'RS256': + keys = [] + for rsakey in RSAKey.objects.all(): + keys.append(jwk_RSAKey(key=importKey(rsakey.key), kid=rsakey.kid)) - for rsakey in RSAKey.objects.all(): - keys.append(jwk_RSAKey(key=importKey(rsakey.key), kid=rsakey.kid)) - - if not keys: - raise Exception('You must add at least one RSA Key.') + if not keys: + raise Exception('You must add at least one RSA Key.') + elif alg == 'HS256': + keys = [SYMKey(key=client.client_secret, alg=alg)] + else: + raise Exception('Unsupported key algorithm.') - _jws = JWS(payload, alg='RS256') + _jws = JWS(payload, alg=alg) return _jws.sign_compact(keys) diff --git a/oidc_provider/migrations/0011_client_jwt_alg.py b/oidc_provider/migrations/0011_client_jwt_alg.py new file mode 100644 index 0000000..d5e552b --- /dev/null +++ b/oidc_provider/migrations/0011_client_jwt_alg.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2016-03-22 17:42 +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='jwt_alg', + 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/models.py b/oidc_provider/models.py index 68ace73..c2fe67f 100644 --- a/oidc_provider/models.py +++ b/oidc_provider/models.py @@ -16,11 +16,16 @@ class Client(models.Model): ('id_token token', 'id_token token (Implicit Flow)'), ] + JWT_ALGS = [ + ('HS256', 'HS256'), + ('RS256', 'RS256'), + ] + name = models.CharField(max_length=100, default='') 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) + jwt_alg = models.CharField(max_length=10, choices=JWT_ALGS, default='RS256', verbose_name=_(u'JWT Algorithm')) 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 f44b5a1114bb208ffe2c89dbab2cee7cd7f02b7c Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Tue, 22 Mar 2016 20:48:30 -0300 Subject: [PATCH 02/52] Update provider info supporting HS256 id_token sign alg. --- 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 01f5d1b..e3da63b 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -181,7 +181,7 @@ class ProviderInfoView(View): dic['jwks_uri'] = SITE_URL + reverse('oidc_provider:jwks') - dic['id_token_signing_alg_values_supported'] = ['RS256'] + dic['id_token_signing_alg_values_supported'] = ['HS256', 'RS256'] # See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes dic['subject_types_supported'] = ['public'] From 6e8af74f76ec327ea2cba7cca7987b6ad3bba944 Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Wed, 6 Apr 2016 18:03:30 -0300 Subject: [PATCH 03/52] 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 04/52] 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 05/52] 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 06/52] 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 07/52] 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 08/52] 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 09/52] 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 10/52] 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 11/52] 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 12/52] 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 13/52] 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 14/52] 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 15/52] 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 16/52] 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 17/52] 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 18/52] 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 19/52] 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 20/52] 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 21/52] 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). From fa4c95e97dd5bb22666836fbc207d913cc3645bd Mon Sep 17 00:00:00 2001 From: Ignacio Date: Sat, 16 Apr 2016 17:53:28 -0300 Subject: [PATCH 22/52] Modify example project template. Conflicts: example_project/provider_app/templates/base.html example_project/provider_app/templates/login.html example_project/provider_app/templates/oidc_provider/authorize.html example_project/provider_app/templates/oidc_provider/error.html --- example_project/README.md | 2 +- .../provider_app/static/css/custom.css | 25 +++----- .../provider_app/templates/base.html | 58 ++++++++++--------- .../provider_app/templates/home.html | 55 +++++------------- .../provider_app/templates/login.html | 50 +++++++++------- .../templates/oidc_provider/authorize.html | 37 ++++++------ .../templates/oidc_provider/error.html | 11 ++-- 7 files changed, 109 insertions(+), 129 deletions(-) diff --git a/example_project/README.md b/example_project/README.md index cad83d3..ab1fd6c 100644 --- a/example_project/README.md +++ b/example_project/README.md @@ -1,6 +1,6 @@ # Example Project -![Example Project](http://s12.postimg.org/e4uwlsi0d/Screenshot_from_2016_02_02_13_15_26.png) +![Example Project](http://i.imgur.com/IK3OZjx.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. diff --git a/example_project/provider_app/static/css/custom.css b/example_project/provider_app/static/css/custom.css index e522417..0f6eaa3 100644 --- a/example_project/provider_app/static/css/custom.css +++ b/example_project/provider_app/static/css/custom.css @@ -1,22 +1,15 @@ -@import url(https://fonts.googleapis.com/css?family=Open+Sans:300,400,800); - -html { - font-size: 16px; -} - body { - font-family: 'Open Sans', sans-serif; - font-weight: 400; - height: auto; - padding-top: 10px; + background-color: #536dfe; + display: flex; + min-height: 100vh; + flex-direction: column; } -.ui.huge.header { - font-size: 46px; - font-weight: 800; +#main-container { + flex: 1 0 auto; + padding-top: 40px; } -.ui.segment { - font-size: 18px; - font-weight: 300; +footer { + padding-top: 0px !important; } \ No newline at end of file diff --git a/example_project/provider_app/templates/base.html b/example_project/provider_app/templates/base.html index d0c1cf1..bc5903d 100644 --- a/example_project/provider_app/templates/base.html +++ b/example_project/provider_app/templates/base.html @@ -6,42 +6,46 @@ - - + - OIDC Provider Example + OpenID Provider Example - + + - - {% block content %}{% endblock %} - -
-
-
- View the project on Github. Example by Juan Ignacio Fiorentino. + +
- - +
+ {% block content %}{% endblock %} +
+ + + + + \ No newline at end of file diff --git a/example_project/provider_app/templates/home.html b/example_project/provider_app/templates/home.html index 7ede9e4..ec76385 100644 --- a/example_project/provider_app/templates/home.html +++ b/example_project/provider_app/templates/home.html @@ -4,46 +4,21 @@ {% block content %} -
-
-
- -

Congratulations! It works.
... what's next?

-
-
-
-
-
-
-

Now that you are an OpenID Connect Provider, start by creating your clients here.

-

Also check that you've created at least one server key, do it here.

-

Server Endpoints

-
-
- {% url 'oidc_provider:provider_info' %} -
The configuration information of the provider. Read more.
-
-
- {% url 'oidc_provider:jwks' %} -
JavaScript Object Notation (JSON) data structure that represents a cryptographic key.
-
-
- {% url 'oidc_provider:authorize' %} -
This endpoint performs Authentication of the End-User. Read more.
-
-
- {% url 'oidc_provider:token' %} -
Used to obtain an Access Token, an ID Token, and optionally a Refresh Token. Read more.
-
-
- {% url 'oidc_provider:userinfo' %} -
OAuth 2.0 Protected Resource that returns Claims about the authenticated End-User. Read more.
-
-
- {% url 'oidc_provider:logout' %} -
Used to notify the OP that the End-User has logged out of the site. Read more.
-
-
+
+
+
+
+

Example of an OpenID Connect 1.0 Provider. Built with the Django Framework and django-oidc-provider package.

+

Start by creating your clients here.

+

Also check that you've created at least one server key, do it here.

+
diff --git a/example_project/provider_app/templates/login.html b/example_project/provider_app/templates/login.html index 8f3b7a9..e9708ca 100644 --- a/example_project/provider_app/templates/login.html +++ b/example_project/provider_app/templates/login.html @@ -1,28 +1,38 @@ {% extends 'base.html' %} +{% load i18n %} + {% 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 %} + +
+
+ account_circle + + +
+
+
+
+ lock + + +
+
+ +
+
- {% endif %} -
- {% csrf_token %} - -
- - -
-
- - -
- -
diff --git a/example_project/provider_app/templates/oidc_provider/authorize.html b/example_project/provider_app/templates/oidc_provider/authorize.html index 6df2ed5..3e2a8b3 100644 --- a/example_project/provider_app/templates/oidc_provider/authorize.html +++ b/example_project/provider_app/templates/oidc_provider/authorize.html @@ -2,25 +2,24 @@ {% 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..31a221c 100644 --- a/example_project/provider_app/templates/oidc_provider/error.html +++ b/example_project/provider_app/templates/oidc_provider/error.html @@ -2,12 +2,11 @@ {% block content %} -
-
-
- -
-
{{ error }}
+
+
+
+
+

{{ error }}

{{ description }}

From b8f442184c73f46a776fc073b288b7fe995dbac1 Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Mon, 25 Apr 2016 14:51:00 -0300 Subject: [PATCH 23/52] Remove migration. --- .../migrations/0011_client_jwt_alg.py | 20 ------------------- 1 file changed, 20 deletions(-) delete mode 100644 oidc_provider/migrations/0011_client_jwt_alg.py diff --git a/oidc_provider/migrations/0011_client_jwt_alg.py b/oidc_provider/migrations/0011_client_jwt_alg.py deleted file mode 100644 index d5e552b..0000000 --- a/oidc_provider/migrations/0011_client_jwt_alg.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9 on 2016-03-22 17:42 -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='jwt_alg', - field=models.CharField(choices=[(b'HS256', b'HS256'), (b'RS256', b'RS256')], default=b'RS256', max_length=10, verbose_name='JWT Algorithm'), - ), - ] From a0c7b3c0c40af08c6eccf8a2731fb21a9804871e Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Mon, 25 Apr 2016 17:33:52 -0300 Subject: [PATCH 24/52] Create migrations. Improve docs. --- CHANGELOG.md | 1 + docs/sections/contribute.rst | 10 ++++++++++ .../migrations/0014_client_jwt_alg.py | 20 +++++++++++++++++++ oidc_provider/views.py | 4 ++-- 4 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 oidc_provider/migrations/0014_client_jwt_alg.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 811ee36..9603169 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ 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. - Support for prompt parameter. +- Support for different client JWT tokens algorithm. ##### Fixed - Not auto-approve requests for non-confidential clients (publics). diff --git a/docs/sections/contribute.rst b/docs/sections/contribute.rst index 9839cca..fd153c7 100644 --- a/docs/sections/contribute.rst +++ b/docs/sections/contribute.rst @@ -22,3 +22,13 @@ If you have a Django project properly configured with the package. Then just run $ python manage.py test --settings oidc_provider.tests.app.settings oidc_provider Also tests run on every commit to the project, we use `travis `_ for this. + +Improve Documentation +===================== + +We use `Sphinx `_ for generate this documentation. I you want to add or modify something just: + +* Install Sphinx ``pip install sphinx`` and this theme ``pip install sphinx-rtd-theme``. +* Move inside the docs folder. ``cd docs/`` +* Generate the HTML. ``make html`` +* Open ``docs/_build/html/index.html`` on a browser. diff --git a/oidc_provider/migrations/0014_client_jwt_alg.py b/oidc_provider/migrations/0014_client_jwt_alg.py new file mode 100644 index 0000000..d2b096c --- /dev/null +++ b/oidc_provider/migrations/0014_client_jwt_alg.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2016-04-25 18:02 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oidc_provider', '0013_auto_20160407_1912'), + ] + + operations = [ + 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'), + ), + ] diff --git a/oidc_provider/views.py b/oidc_provider/views.py index ca579ff..a60312c 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -16,7 +16,7 @@ from oidc_provider.lib.endpoints.token import * from oidc_provider.lib.errors import * from oidc_provider.lib.utils.common import redirect, get_issuer from oidc_provider.lib.utils.oauth2 import protected_resource_view -from oidc_provider.models import Client, RSAKey +from oidc_provider.models import RESPONSE_TYPE_CHOICES, RSAKey from oidc_provider import settings @@ -187,7 +187,7 @@ class ProviderInfoView(View): dic['userinfo_endpoint'] = SITE_URL + reverse('oidc_provider:userinfo') dic['end_session_endpoint'] = SITE_URL + reverse('oidc_provider:logout') - types_supported = [x[0] for x in Client.RESPONSE_TYPE_CHOICES] + types_supported = [x[0] for x in RESPONSE_TYPE_CHOICES] dic['response_types_supported'] = types_supported dic['jwks_uri'] = SITE_URL + reverse('oidc_provider:jwks') From ad93b777dbd3990a459773d976c21a6a32e78892 Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Tue, 26 Apr 2016 17:33:43 -0300 Subject: [PATCH 25/52] Bump version v0.3.2. --- CHANGELOG.md | 2 ++ setup.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9603169..ce72f72 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.3.2] - 2016-04-26 + ##### Added - Choose type of client on creation. - Implement Proof Key for Code Exchange by OAuth Public Clients. diff --git a/setup.py b/setup.py index cb1a60e..9ccaeff 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) setup( name='django-oidc-provider', - version='0.3.1', + version='0.3.2', packages=[ 'oidc_provider', 'oidc_provider/lib', 'oidc_provider/lib/endpoints', 'oidc_provider/lib/utils', 'oidc_provider/tests', 'oidc_provider/tests/app', From ef88b6ca33feb5ae33e51eb0b347feac1c0a7134 Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Wed, 27 Apr 2016 15:55:02 -0300 Subject: [PATCH 26/52] Improve docs. --- docs/sections/installation.rst | 2 +- docs/sections/relyingparties.rst | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/sections/installation.rst b/docs/sections/installation.rst index 61528e3..a223691 100644 --- a/docs/sections/installation.rst +++ b/docs/sections/installation.rst @@ -6,7 +6,7 @@ Installation Requirements ============ -* Python: ``2.7`` ``3.4`` +* Python: ``2.7`` ``3.4`` ``3.5`` * Django: ``1.7`` ``1.8`` ``1.9`` Quick Installation diff --git a/docs/sections/relyingparties.rst b/docs/sections/relyingparties.rst index 1c6d80c..f9ed7d3 100644 --- a/docs/sections/relyingparties.rst +++ b/docs/sections/relyingparties.rst @@ -11,6 +11,17 @@ OAuth defines two client types, based on their ability to maintain the confident * ``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. +Properties +========== + +* ``name``: Human-readable name for your client. +* ``client_type``: Values are ``confidential`` and ``public``. +* ``client_id``: Client unique identifier. +* ``client_secret``: Client secret for confidential applications. +* ``response_type``: Values are ``code``, ``id_token`` and ``id_token token``. +* ``jwt_alg``: Clients can choose wich algorithm will be used to sign id_tokens. Values are ``HS256`` and ``RS256``. +* ``date_created``: Date automatically added when created. + Using the admin =============== From 497f2f3a68bfa3ab267bb7bf60bd1ad2c0725159 Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Tue, 3 May 2016 12:17:22 -0300 Subject: [PATCH 27/52] Bump version v0.3.3. --- CHANGELOG.md | 5 +++++ oidc_provider/lib/endpoints/authorize.py | 7 +++---- oidc_provider/templates/oidc_provider/hidden_inputs.html | 6 +++--- setup.py | 2 +- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce72f72..b73fd71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. ### [Unreleased] +### [0.3.3] - 2016-05-03 + +##### Fixed +- Important bug with PKCE and form submit in Auth Request. + ### [0.3.2] - 2016-04-26 ##### Added diff --git a/oidc_provider/lib/endpoints/authorize.py b/oidc_provider/lib/endpoints/authorize.py index 5a89a9b..09365e7 100644 --- a/oidc_provider/lib/endpoints/authorize.py +++ b/oidc_provider/lib/endpoints/authorize.py @@ -54,12 +54,11 @@ class AuthorizeEndpoint(object): self.params.response_type = query_dict.get('response_type', '') 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') - self.params.code_challenge_method = query_dict.get('code_challenge_method') + self.params.code_challenge = query_dict.get('code_challenge', '') + self.params.code_challenge_method = query_dict.get('code_challenge_method', '') def validate_params(self): # Client validation. diff --git a/oidc_provider/templates/oidc_provider/hidden_inputs.html b/oidc_provider/templates/oidc_provider/hidden_inputs.html index 2bff39d..596414e 100644 --- a/oidc_provider/templates/oidc_provider/hidden_inputs.html +++ b/oidc_provider/templates/oidc_provider/hidden_inputs.html @@ -3,6 +3,6 @@ - - - +{% if params.nonce %}{% endif %} +{% if params.code_challenge %}{% endif %} +{% if params.code_challenge_method %}{% endif %} diff --git a/setup.py b/setup.py index 9ccaeff..9a94039 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) setup( name='django-oidc-provider', - version='0.3.2', + version='0.3.3', packages=[ 'oidc_provider', 'oidc_provider/lib', 'oidc_provider/lib/endpoints', 'oidc_provider/lib/utils', 'oidc_provider/tests', 'oidc_provider/tests/app', From be5656bcf4ba9620293bcd3977a531701a271399 Mon Sep 17 00:00:00 2001 From: Si Feng Date: Wed, 25 May 2016 14:58:58 -0700 Subject: [PATCH 28/52] Make `SITE_URL` optional. --- oidc_provider/lib/endpoints/authorize.py | 4 +-- oidc_provider/lib/endpoints/token.py | 6 ++-- oidc_provider/lib/utils/common.py | 23 +++++++++++++-- oidc_provider/lib/utils/token.py | 6 ++-- oidc_provider/settings.py | 7 +++-- oidc_provider/tests/test_utils.py | 37 ++++++++++++++++++++++-- oidc_provider/views.py | 19 +++++------- 7 files changed, 75 insertions(+), 27 deletions(-) diff --git a/oidc_provider/lib/endpoints/authorize.py b/oidc_provider/lib/endpoints/authorize.py index 09365e7..43ff43f 100644 --- a/oidc_provider/lib/endpoints/authorize.py +++ b/oidc_provider/lib/endpoints/authorize.py @@ -1,4 +1,3 @@ -from datetime import timedelta import logging try: from urllib import urlencode @@ -126,7 +125,8 @@ class AuthorizeEndpoint(object): id_token_dic = create_id_token( user=self.request.user, aud=self.client.client_id, - nonce=self.params.nonce) + nonce=self.params.nonce, + request=self.request) query_fragment['id_token'] = encode_id_token(id_token_dic, self.client) else: id_token_dic = {} diff --git a/oidc_provider/lib/endpoints/token.py b/oidc_provider/lib/endpoints/token.py index b5652ba..200cb2c 100644 --- a/oidc_provider/lib/endpoints/token.py +++ b/oidc_provider/lib/endpoints/token.py @@ -1,4 +1,4 @@ -from base64 import b64decode, urlsafe_b64decode, urlsafe_b64encode +from base64 import b64decode, urlsafe_b64encode import hashlib import logging import re @@ -7,9 +7,7 @@ try: except ImportError: from urllib import unquote -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 * @@ -138,6 +136,7 @@ class TokenEndpoint(object): user=self.code.user, aud=self.client.client_id, nonce=self.code.nonce, + request=self.request, ) else: id_token_dic = {} @@ -171,6 +170,7 @@ class TokenEndpoint(object): user=self.token.user, aud=self.client.client_id, nonce=None, + request=self.request, ) else: id_token_dic = {} diff --git a/oidc_provider/lib/utils/common.py b/oidc_provider/lib/utils/common.py index 9d37f4f..78305c6 100644 --- a/oidc_provider/lib/utils/common.py +++ b/oidc_provider/lib/utils/common.py @@ -13,12 +13,31 @@ def redirect(uri): return response -def get_issuer(): +def get_site_url(site_url=None, request=None): + """ + Construct the site url. + + Orders to decide site url: + 1. valid `site_url` parameter + 2. valid `SITE_URL` in settings + 3. construct from `request` object + """ + site_url = site_url or settings.get('SITE_URL') + if site_url: + return site_url + elif request: + return '{}://{}'.format(request.scheme, request.get_host()) + else: + raise Exception('Either pass `site_url`, ' + 'or set `SITE_URL` in settings, ' + 'or pass `request` object.') + +def get_issuer(site_url=None, request=None): """ Construct the issuer full url. Basically is the site url with some path appended. """ - site_url = settings.get('SITE_URL') + site_url = get_site_url(site_url=site_url, request=request) path = reverse('oidc_provider:provider_info') \ .split('/.well-known/openid-configuration')[0] issuer = site_url + path diff --git a/oidc_provider/lib/utils/token.py b/oidc_provider/lib/utils/token.py index 10f4a18..fc0880d 100644 --- a/oidc_provider/lib/utils/token.py +++ b/oidc_provider/lib/utils/token.py @@ -1,11 +1,9 @@ -from base64 import urlsafe_b64decode, urlsafe_b64encode from datetime import timedelta import time import uuid from Crypto.PublicKey.RSA import importKey from django.utils import timezone -from hashlib import md5 from jwkest.jwk import RSAKey as jwk_RSAKey from jwkest.jwk import SYMKey from jwkest.jws import JWS @@ -15,7 +13,7 @@ from oidc_provider.models import * from oidc_provider import settings -def create_id_token(user, aud, nonce): +def create_id_token(user, aud, nonce, request=None): """ Receives a user object and aud (audience). Then creates the id_token dictionary. @@ -35,7 +33,7 @@ def create_id_token(user, aud, nonce): auth_time = int(time.mktime(user_auth_time.timetuple())) dic = { - 'iss': get_issuer(), + 'iss': get_issuer(request=request), 'sub': sub, 'aud': str(aud), 'exp': exp_time, diff --git a/oidc_provider/settings.py b/oidc_provider/settings.py index b7890d1..49530f4 100644 --- a/oidc_provider/settings.py +++ b/oidc_provider/settings.py @@ -4,6 +4,9 @@ from django.conf import settings class DefaultSettings(object): + required_attrs = ( + 'LOGIN_URL', + ) @property def LOGIN_URL(self): @@ -15,7 +18,7 @@ class DefaultSettings(object): @property def SITE_URL(self): """ - REQUIRED. The OP server url. + OPTIONAL. The OP server url. """ return None @@ -131,7 +134,7 @@ def get(name, import_str=False): value = getattr(default_settings, name) value = getattr(settings, name) except AttributeError: - if value is None: + if value is None and value in default_settings.required_attrs: raise Exception('You must set ' + name + ' in your settings.') value = import_from_str(value) if import_str else value diff --git a/oidc_provider/tests/test_utils.py b/oidc_provider/tests/test_utils.py index 0357014..32bdf8d 100644 --- a/oidc_provider/tests/test_utils.py +++ b/oidc_provider/tests/test_utils.py @@ -1,13 +1,44 @@ -from django.conf import settings from django.test import TestCase from oidc_provider.lib.utils.common import get_issuer +class Request(object): + """ + Mock request object. + """ + scheme = 'http' + + def get_host(self): + return 'host-from-request:8888' + + class CommonTest(TestCase): """ Test cases for common utils. """ def test_get_issuer(self): - issuer = get_issuer() - self.assertEqual(issuer, settings.SITE_URL + '/openid') + request = Request() + + # from default settings + self.assertEqual(get_issuer(), + 'http://localhost:8000/openid') + + # from custom settings + with self.settings(SITE_URL='http://otherhost:8000'): + self.assertEqual(get_issuer(), + 'http://otherhost:8000/openid') + + # `SITE_URL` not set, from `request` + with self.settings(SITE_URL=''): + self.assertEqual(get_issuer(request=request), + 'http://host-from-request:8888/openid') + + # use settings first if both are provided + self.assertEqual(get_issuer(request=request), + 'http://localhost:8000/openid') + + # `site_url` can even be overridden manually + self.assertEqual(get_issuer(site_url='http://127.0.0.1:9000', + request=request), + 'http://127.0.0.1:9000/openid') diff --git a/oidc_provider/views.py b/oidc_provider/views.py index a60312c..1b1bba4 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -1,5 +1,3 @@ -import logging - from Crypto.PublicKey import RSA from django.contrib.auth.views import redirect_to_login, logout from django.core.urlresolvers import reverse @@ -14,7 +12,7 @@ from oidc_provider.lib.claims import StandardScopeClaims from oidc_provider.lib.endpoints.authorize import * from oidc_provider.lib.endpoints.token import * from oidc_provider.lib.errors import * -from oidc_provider.lib.utils.common import redirect, get_issuer +from oidc_provider.lib.utils.common import redirect, get_site_url, get_issuer from oidc_provider.lib.utils.oauth2 import protected_resource_view from oidc_provider.models import RESPONSE_TYPE_CHOICES, RSAKey from oidc_provider import settings @@ -178,19 +176,18 @@ class ProviderInfoView(View): def get(self, request, *args, **kwargs): dic = dict() - dic['issuer'] = get_issuer() + site_url = get_site_url(request=request) + dic['issuer'] = get_issuer(site_url=site_url, request=request) - SITE_URL = settings.get('SITE_URL') - - dic['authorization_endpoint'] = SITE_URL + reverse('oidc_provider:authorize') - 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:logout') + dic['authorization_endpoint'] = site_url + reverse('oidc_provider:authorize') + 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:logout') types_supported = [x[0] for x in RESPONSE_TYPE_CHOICES] dic['response_types_supported'] = types_supported - dic['jwks_uri'] = SITE_URL + reverse('oidc_provider:jwks') + dic['jwks_uri'] = site_url + reverse('oidc_provider:jwks') dic['id_token_signing_alg_values_supported'] = ['HS256', 'RS256'] From 86a921aba732e609409a351ebe5203deb01fe462 Mon Sep 17 00:00:00 2001 From: Si Feng Date: Wed, 25 May 2016 15:21:27 -0700 Subject: [PATCH 29/52] Fix typo --- oidc_provider/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oidc_provider/settings.py b/oidc_provider/settings.py index 49530f4..21de1df 100644 --- a/oidc_provider/settings.py +++ b/oidc_provider/settings.py @@ -134,7 +134,7 @@ def get(name, import_str=False): value = getattr(default_settings, name) value = getattr(settings, name) except AttributeError: - if value is None and value in default_settings.required_attrs: + if value is None and name in default_settings.required_attrs: raise Exception('You must set ' + name + ' in your settings.') value = import_from_str(value) if import_str else value From de3da208ee779d8286ba4e68a5a0e16b7b78b122 Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Thu, 26 May 2016 17:05:16 -0300 Subject: [PATCH 30/52] Update docs. --- docs/sections/installation.rst | 1 - docs/sections/settings.rst | 16 +++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/sections/installation.rst b/docs/sections/installation.rst index a223691..3c4f811 100644 --- a/docs/sections/installation.rst +++ b/docs/sections/installation.rst @@ -46,5 +46,4 @@ Generate server RSA key and run migrations (if you don't):: Add required variables to your project settings:: - SITE_URL = 'http://localhost:8000' LOGIN_URL = '/accounts/login/' diff --git a/docs/sections/settings.rst b/docs/sections/settings.rst index 23f295b..ebae75b 100644 --- a/docs/sections/settings.rst +++ b/docs/sections/settings.rst @@ -5,13 +5,6 @@ Settings Customize your provider so fit your project needs. -SITE_URL -======== - -REQUIRED. ``str``. The OP server url. - -For example ``http://localhost:8000``. - LOGIN_URL ========= @@ -19,6 +12,15 @@ REQUIRED. ``str``. Used to log the user in. `Read more in Django docs Date: Thu, 26 May 2016 17:06:06 -0300 Subject: [PATCH 31/52] Edit CHANGELOG. --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b73fd71..c4a7e47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. ### [Unreleased] +##### Changed +- Make SITE_URL setting optional. + ### [0.3.3] - 2016-05-03 ##### Fixed From eea590e006a450176c67499a3a13a7cc3e288b85 Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Mon, 30 May 2016 13:28:07 -0300 Subject: [PATCH 32/52] Modify scope claims class. --- docs/sections/settings.rst | 29 ++++++++++------------------- oidc_provider/lib/claims.py | 29 +++++++++-------------------- oidc_provider/views.py | 1 + 3 files changed, 20 insertions(+), 39 deletions(-) diff --git a/docs/sections/settings.rst b/docs/sections/settings.rst index ebae75b..29b7837 100644 --- a/docs/sections/settings.rst +++ b/docs/sections/settings.rst @@ -46,9 +46,9 @@ Expressed in seconds. Default is ``60*10``. OIDC_EXTRA_SCOPE_CLAIMS ======================= -OPTIONAL. ``str``. A string with the location of your class. Default is ``oidc_provider.lib.claims.AbstractScopeClaims``. +OPTIONAL. ``str``. A string with the location of your class. Default is ``oidc_provider.lib.claims.ScopeClaims``. -Used to add extra scopes specific for your app. This class MUST inherit ``AbstractScopeClaims``. +Used to add extra scopes specific for your app. This class MUST inherit ``ScopeClaims``. OpenID Connect Clients will use scope values to specify what access privileges are being requested for Access Tokens. @@ -56,24 +56,15 @@ OpenID Connect Clients will use scope values to specify what access privileges a Check out an example of how to implement it:: - from oidc_provider.lib.claims import AbstractScopeClaims + from oidc_provider.lib.claims import ScopeClaims - class MyAppScopeClaims(AbstractScopeClaims): + class MyAppScopeClaims(ScopeClaims): - def setup(self): - # Here you can load models that will be used - # in more than one scope for example. - # print self.user - # print self.scopes - try: - self.some_model = SomeModel.objects.get(user=self.user) - except SomeModel.DoesNotExist: - # Create an empty model object. - self.some_model = SomeModel() - - def scope_books(self, user): - - # Here you can search books for this user. + def scope_books(self): + # Here, for example, you can search books for this user. + # self.user - Django user instance. + # self.userinfo - Instance of your custom OIDC_USERINFO class. + # self.scopes - List of scopes requested. dic = { 'books_readed': books_readed_count, @@ -83,7 +74,7 @@ Check out an example of how to implement it:: You can create our own scopes using the convention: -``def scope_SCOPENAMEHERE(self, user):`` +``def scope_somename(self):`` If a field is empty or ``None`` will be cleaned from the response. diff --git a/oidc_provider/lib/claims.py b/oidc_provider/lib/claims.py index d71a95d..660ae05 100644 --- a/oidc_provider/lib/claims.py +++ b/oidc_provider/lib/claims.py @@ -3,17 +3,13 @@ from django.utils.translation import ugettext as _ from oidc_provider import settings -class AbstractScopeClaims(object): +class ScopeClaims(object): def __init__(self, user, scopes): self.user = user + self.userinfo = settings.get('OIDC_USERINFO', import_str=True).get_by_user(self.user) self.scopes = scopes - self.setup() - - def setup(self): - pass - def create_response_dic(self): """ Generate the dic that will be jsonify. Checking scopes given vs @@ -25,7 +21,7 @@ class AbstractScopeClaims(object): for scope in self.scopes: if scope in self._scopes_registered(): - dic.update(getattr(self, 'scope_' + scope)(self.user)) + dic.update(getattr(self, 'scope_' + scope)()) dic = self._clean_dic(dic) @@ -61,20 +57,13 @@ class AbstractScopeClaims(object): return aux_dic -class StandardScopeClaims(AbstractScopeClaims): +class StandardScopeClaims(ScopeClaims): """ Based on OpenID Standard Claims. See: http://openid.net/specs/openid-connect-core-1_0.html#StandardClaims """ - - def setup(self): - try: - self.userinfo = settings.get('OIDC_USERINFO', - import_str=True).get_by_user(self.user) - except: - self.userinfo = None - - def scope_profile(self, user): + + def scope_profile(self): dic = { 'name': getattr(self.userinfo, 'name', None), 'given_name': getattr(self.userinfo, 'given_name', None), @@ -94,7 +83,7 @@ class StandardScopeClaims(AbstractScopeClaims): return dic - def scope_email(self, user): + def scope_email(self): dic = { 'email': getattr(self.user, 'email', None), 'email_verified': getattr(self.userinfo, 'email_verified', None), @@ -102,7 +91,7 @@ class StandardScopeClaims(AbstractScopeClaims): return dic - def scope_phone(self, user): + def scope_phone(self): dic = { 'phone_number': getattr(self.userinfo, 'phone_number', None), 'phone_number_verified': getattr(self.userinfo, 'phone_number_verified', None), @@ -110,7 +99,7 @@ class StandardScopeClaims(AbstractScopeClaims): return dic - def scope_address(self, user): + def scope_address(self): dic = { 'address': { 'formatted': getattr(self.userinfo, 'address_formatted', None), diff --git a/oidc_provider/views.py b/oidc_provider/views.py index 1b1bba4..7797034 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -168,6 +168,7 @@ def userinfo(request, *args, **kwargs): response = JsonResponse(dic, status=200) response['Cache-Control'] = 'no-store' response['Pragma'] = 'no-cache' + return response From 21e8e89d35d2bc693622dbf6492f856b77c47225 Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Mon, 30 May 2016 14:26:25 -0300 Subject: [PATCH 33/52] Edit default setting. --- oidc_provider/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oidc_provider/settings.py b/oidc_provider/settings.py index 21de1df..00f2c70 100644 --- a/oidc_provider/settings.py +++ b/oidc_provider/settings.py @@ -43,7 +43,7 @@ class DefaultSettings(object): OPTIONAL. A string with the location of your class. Used to add extra scopes specific for your app. """ - return 'oidc_provider.lib.claims.AbstractScopeClaims' + return 'oidc_provider.lib.claims.ScopeClaims' @property def OIDC_IDTOKEN_EXPIRE(self): From 74586f3e1919fcc7ec988a09f70c0e45acbe6967 Mon Sep 17 00:00:00 2001 From: John Peach Date: Tue, 31 May 2016 10:33:19 -0700 Subject: [PATCH 34/52] grammar corrections --- docs/index.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index f33b663..ceb0781 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -13,8 +13,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. -* This cover **Authorization Code Flow** and **Implicit Flow**, NO support for **Hybrid Flow** at this moment. -* Only support for requesting Claims using Scope Values. +* This library covers **Authorization Code Flow** and **Implicit Flow**, NO support for **Hybrid Flow** at this moment. +* Supports only for requesting Claims using Scope values. -------------------------------------------------------------------------------- From 70c7d128c15a42442972dede53634d6cff1ea7c7 Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Wed, 1 Jun 2016 12:09:40 -0300 Subject: [PATCH 35/52] Refactoring views.py. --- oidc_provider/views.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/oidc_provider/views.py b/oidc_provider/views.py index 7797034..54229c7 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -48,12 +48,21 @@ class AuthorizeView(View): and not (authorize.params.prompt == 'consent'): return redirect(authorize.create_response_uri()) + 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) + # Generate hidden inputs for the form. context = { 'params': authorize.params, } - hidden_inputs = render_to_string( - 'oidc_provider/hidden_inputs.html', context) + hidden_inputs = render_to_string('oidc_provider/hidden_inputs.html', context) # Remove `openid` from scope list # since we don't need to print it. @@ -66,16 +75,6 @@ class AuthorizeView(View): 'params': authorize.params, } - 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': @@ -103,7 +102,7 @@ class AuthorizeView(View): try: authorize.validate_params() - + if not request.POST.get('allow'): raise AuthorizeError(authorize.params.redirect_uri, 'access_denied', From 41f23afa3150dbe7e885bdfaaba306201b1ddcd6 Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Fri, 10 Jun 2016 10:57:31 -0300 Subject: [PATCH 36/52] Add missing migration. --- .../migrations/0015_change_client_code.py | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 oidc_provider/migrations/0015_change_client_code.py diff --git a/oidc_provider/migrations/0015_change_client_code.py b/oidc_provider/migrations/0015_change_client_code.py new file mode 100644 index 0000000..bfffd57 --- /dev/null +++ b/oidc_provider/migrations/0015_change_client_code.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-06-10 13:55 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oidc_provider', '0014_client_jwt_alg'), + ] + + operations = [ + migrations.AlterField( + model_name='client', + name='_redirect_uris', + field=models.TextField(default='', help_text='Enter each URI on a new line.', verbose_name='Redirect URI'), + ), + migrations.AlterField( + model_name='client', + name='client_secret', + field=models.CharField(blank=True, default='', max_length=255), + ), + 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), + ), + 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'), + ), + migrations.AlterField( + model_name='client', + name='name', + field=models.CharField(default='', max_length=100), + ), + 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), + ), + migrations.AlterField( + model_name='code', + name='_scope', + field=models.TextField(default=''), + ), + migrations.AlterField( + model_name='code', + name='nonce', + field=models.CharField(blank=True, default='', max_length=255), + ), + migrations.AlterField( + model_name='token', + name='_scope', + field=models.TextField(default=''), + ), + migrations.AlterField( + model_name='userconsent', + name='_scope', + field=models.TextField(default=''), + ), + ] From 0e7d3cb3882139922c24e2ed1628930f0b225673 Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Fri, 10 Jun 2016 12:27:36 -0300 Subject: [PATCH 37/52] Bump version v0.3.4. --- CHANGELOG.md | 5 +++++ setup.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4a7e47..c63c6ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,14 @@ All notable changes to this project will be documented in this file. ### [Unreleased] +### [0.3.4] - 2016-06-10 + ##### Changed - Make SITE_URL setting optional. +##### Fixed +- Missing migration. + ### [0.3.3] - 2016-05-03 ##### Fixed diff --git a/setup.py b/setup.py index 9a94039..70ed75e 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) setup( name='django-oidc-provider', - version='0.3.3', + version='0.3.4', packages=[ 'oidc_provider', 'oidc_provider/lib', 'oidc_provider/lib/endpoints', 'oidc_provider/lib/utils', 'oidc_provider/tests', 'oidc_provider/tests/app', From 5255719700281faad46cf700a7e0d9a7e55ca88b Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Mon, 13 Jun 2016 12:15:10 -0300 Subject: [PATCH 38/52] Add date_given to UserConsent model. Add verbose names in models. --- oidc_provider/lib/endpoints/authorize.py | 18 +- .../0016_userconsent_and_verbosenames.py | 165 ++++++++++++++++++ oidc_provider/models.py | 46 ++--- oidc_provider/tests/app/settings.py | 2 + 4 files changed, 203 insertions(+), 28 deletions(-) create mode 100644 oidc_provider/migrations/0016_userconsent_and_verbosenames.py diff --git a/oidc_provider/lib/endpoints/authorize.py b/oidc_provider/lib/endpoints/authorize.py index 43ff43f..602ff89 100644 --- a/oidc_provider/lib/endpoints/authorize.py +++ b/oidc_provider/lib/endpoints/authorize.py @@ -34,7 +34,7 @@ class AuthorizeEndpoint(object): self.grant_type = None # Determine if it's an OpenID Authentication request (or OAuth2). - self.is_authentication = 'openid' in self.params.scope + self.is_authentication = 'openid' in self.params.scope def _extract_params(self): """ @@ -53,7 +53,7 @@ class AuthorizeEndpoint(object): self.params.response_type = query_dict.get('response_type', '') 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.code_challenge = query_dict.get('code_challenge', '') @@ -113,7 +113,7 @@ class AuthorizeEndpoint(object): is_authentication=self.is_authentication, code_challenge=self.params.code_challenge, code_challenge_method=self.params.code_challenge_method) - + code.save() query_params['code'] = code.code @@ -169,18 +169,24 @@ class AuthorizeEndpoint(object): Return None. """ - expires_at = timezone.now() + timedelta( + date_given = timezone.now() + expires_at = date_given + timedelta( days=settings.get('OIDC_SKIP_CONSENT_EXPIRE')) uc, created = UserConsent.objects.get_or_create( user=self.request.user, client=self.client, - defaults={'expires_at': expires_at}) + defaults={ + 'expires_at': expires_at, + 'date_given': date_given, + } + ) uc.scope = self.params.scope - # Rewrite expires_at if object already exists. + # Rewrite expires_at and date_given if object already exists. if not created: uc.expires_at = expires_at + uc.date_given = date_given uc.save() diff --git a/oidc_provider/migrations/0016_userconsent_and_verbosenames.py b/oidc_provider/migrations/0016_userconsent_and_verbosenames.py new file mode 100644 index 0000000..afd043e --- /dev/null +++ b/oidc_provider/migrations/0016_userconsent_and_verbosenames.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.6 on 2016-06-10 17:53 +from __future__ import unicode_literals + +import datetime +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +from django.utils.timezone import utc + + +class Migration(migrations.Migration): + + dependencies = [ + ('oidc_provider', '0015_change_client_code'), + ] + + operations = [ + 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'), + 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'), + ), + migrations.AlterField( + model_name='client', + name='client_id', + field=models.CharField(max_length=255, unique=True, verbose_name='Client ID'), + ), + migrations.AlterField( + model_name='client', + name='client_secret', + field=models.CharField(blank=True, default=b'', max_length=255, verbose_name='Client SECRET'), + ), + 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'), + ), + migrations.AlterField( + model_name='client', + name='date_created', + field=models.DateField(auto_now_add=True, verbose_name='Date Created'), + ), + migrations.AlterField( + model_name='client', + name='name', + field=models.CharField(default=b'', max_length=100, verbose_name='Name'), + ), + 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'), + ), + migrations.AlterField( + model_name='code', + name='_scope', + field=models.TextField(default=b'', verbose_name='Scopes'), + ), + migrations.AlterField( + model_name='code', + name='client', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.Client', verbose_name='Client'), + ), + migrations.AlterField( + model_name='code', + name='code', + field=models.CharField(max_length=255, unique=True, verbose_name='Code'), + ), + migrations.AlterField( + model_name='code', + name='code_challenge', + field=models.CharField(max_length=255, null=True, verbose_name='Code Challenge'), + ), + migrations.AlterField( + model_name='code', + name='code_challenge_method', + field=models.CharField(max_length=255, null=True, verbose_name='Code Challenge Method'), + ), + migrations.AlterField( + model_name='code', + name='expires_at', + field=models.DateTimeField(verbose_name='Expiration Date'), + ), + migrations.AlterField( + model_name='code', + name='is_authentication', + field=models.BooleanField(default=False, verbose_name='Is Authentication?'), + ), + migrations.AlterField( + model_name='code', + name='nonce', + field=models.CharField(blank=True, default=b'', max_length=255, verbose_name='Nonce'), + ), + 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'), + ), + migrations.AlterField( + model_name='rsakey', + name='key', + field=models.TextField(help_text='Paste your private RSA Key here.', verbose_name='Key'), + ), + migrations.AlterField( + model_name='token', + name='_id_token', + field=models.TextField(verbose_name='ID Token'), + ), + migrations.AlterField( + model_name='token', + name='_scope', + field=models.TextField(default=b'', verbose_name='Scopes'), + ), + migrations.AlterField( + model_name='token', + name='access_token', + field=models.CharField(max_length=255, unique=True, verbose_name='Access Token'), + ), + migrations.AlterField( + model_name='token', + name='client', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.Client', verbose_name='Client'), + ), + migrations.AlterField( + model_name='token', + name='expires_at', + field=models.DateTimeField(verbose_name='Expiration Date'), + ), + migrations.AlterField( + model_name='token', + name='refresh_token', + field=models.CharField(max_length=255, null=True, unique=True, verbose_name='Refresh Token'), + ), + 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'), + ), + migrations.AlterField( + model_name='userconsent', + name='_scope', + field=models.TextField(default=b'', verbose_name='Scopes'), + ), + migrations.AlterField( + model_name='userconsent', + name='client', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.Client', verbose_name='Client'), + ), + migrations.AlterField( + model_name='userconsent', + name='expires_at', + field=models.DateTimeField(verbose_name='Expiration Date'), + ), + 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'), + ), + ] diff --git a/oidc_provider/models.py b/oidc_provider/models.py index 4c280e3..7495044 100644 --- a/oidc_provider/models.py +++ b/oidc_provider/models.py @@ -26,15 +26,15 @@ JWT_ALGS = [ 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, blank=True, default='') - response_type = models.CharField(max_length=30, choices=RESPONSE_TYPE_CHOICES) + 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_id = models.CharField(max_length=255, unique=True, verbose_name=_(u'Client ID')) + client_secret = models.CharField(max_length=255, blank=True, default='', 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')) - date_created = models.DateField(auto_now_add=True) + date_created = models.DateField(auto_now_add=True, verbose_name=_(u'Date Created')) - _redirect_uris = models.TextField(default='', verbose_name=_(u'Redirect URI'), help_text=_(u'Enter each URI on a new line.')) + _redirect_uris = models.TextField(default='', verbose_name=_(u'Redirect URIs'), help_text=_(u'Enter each URI on a new line.')) class Meta: verbose_name = _(u'Client') @@ -45,7 +45,7 @@ class Client(models.Model): def __unicode__(self): return self.__str__() - + def redirect_uris(): def fget(self): return self._redirect_uris.splitlines() @@ -61,10 +61,10 @@ class Client(models.Model): class BaseCodeTokenModel(models.Model): - user = models.ForeignKey(settings.AUTH_USER_MODEL) - client = models.ForeignKey(Client) - expires_at = models.DateTimeField() - _scope = models.TextField(default='') + user = models.ForeignKey(settings.AUTH_USER_MODEL, verbose_name=_(u'User')) + client = models.ForeignKey(Client, verbose_name=_(u'Client')) + expires_at = models.DateTimeField(verbose_name=_(u'Expiration Date')) + _scope = models.TextField(default='', verbose_name=_(u'Scopes')) def scope(): def fget(self): @@ -82,18 +82,18 @@ class BaseCodeTokenModel(models.Model): def __unicode__(self): return self.__str__() - + class Meta: abstract = True 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) + 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?')) + 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')) class Meta: verbose_name = _(u'Authorization Code') @@ -102,9 +102,9 @@ class Code(BaseCodeTokenModel): class Token(BaseCodeTokenModel): - access_token = models.CharField(max_length=255, unique=True) - refresh_token = models.CharField(max_length=255, unique=True, null=True) - _id_token = models.TextField() + access_token = models.CharField(max_length=255, unique=True, verbose_name=_(u'Access Token')) + refresh_token = models.CharField(max_length=255, unique=True, null=True, verbose_name=_(u'Refresh Token')) + _id_token = models.TextField(verbose_name=_(u'ID Token')) def id_token(): def fget(self): return json.loads(self._id_token) @@ -120,13 +120,15 @@ class Token(BaseCodeTokenModel): class UserConsent(BaseCodeTokenModel): + date_given = models.DateTimeField(verbose_name=_(u'Date Given')) + class Meta: unique_together = ('user', 'client') class RSAKey(models.Model): - key = models.TextField(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/oidc_provider/tests/app/settings.py b/oidc_provider/tests/app/settings.py index ef5f92b..281b28c 100644 --- a/oidc_provider/tests/app/settings.py +++ b/oidc_provider/tests/app/settings.py @@ -53,6 +53,8 @@ TEMPLATE_DIRS = [ 'oidc_provider/tests/templates', ] +USE_TZ = True + # OIDC Provider settings. SITE_URL = 'http://localhost:8000' From b145c5d47783adfc64b0a9a5c8623f634e6c2f91 Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Mon, 13 Jun 2016 12:16:48 -0300 Subject: [PATCH 39/52] Edit CHANGELOG. --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c63c6ea..1995c8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ### [Unreleased] +##### Added +- Field date_given in UserConsent model. +- Verbose names to all model fields. + ### [0.3.4] - 2016-06-10 ##### Changed From 739b6ef381481c91e5cfb5e3c4a91a0b49a59459 Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Mon, 13 Jun 2016 13:26:33 -0300 Subject: [PATCH 40/52] Provide doc for user consent model. --- docs/index.rst | 4 ++-- docs/sections/userconsent.rst | 19 +++++++++++++++++++ oidc_provider/models.py | 2 +- 3 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 docs/sections/userconsent.rst diff --git a/docs/index.rst b/docs/index.rst index ceb0781..43974a5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -22,12 +22,13 @@ Contents: .. toctree:: :maxdepth: 2 - + sections/installation sections/relyingparties sections/serverkeys sections/templates sections/claims + sections/userconsent sections/oauth2 sections/settings sections/contribute @@ -39,4 +40,3 @@ Indices and tables * :ref:`genindex` * :ref:`modindex` * :ref:`search` - diff --git a/docs/sections/userconsent.rst b/docs/sections/userconsent.rst new file mode 100644 index 0000000..bc74025 --- /dev/null +++ b/docs/sections/userconsent.rst @@ -0,0 +1,19 @@ +.. _userconsent: + +User Consent +############ + +The package store some information after the user grant access to some client. For example, you can use the ``UserConsent`` model to list applications that the user have authorized access. Like Google does `here `_. + + >>> from oidc_provider.models import UserConsent + >>> UserConsent.objects.filter(user__email='some@email.com') + [] + +Properties +========== + +* ``user``: Django user object. +* ``client``: Relying Party object. +* ``expires_at``: Expiration date of the consent. +* ``scope``: Scopes authorized. +* ``date_given``: Date of the authorization. diff --git a/oidc_provider/models.py b/oidc_provider/models.py index 7495044..09b36a2 100644 --- a/oidc_provider/models.py +++ b/oidc_provider/models.py @@ -78,7 +78,7 @@ class BaseCodeTokenModel(models.Model): return timezone.now() >= self.expires_at def __str__(self): - return u'{0} - {1} ({2})'.format(self.client, self.user.email, self.expires_at) + return u'{0} - {1}'.format(self.client, self.user.email) def __unicode__(self): return self.__str__() From d4f25c3dd2f1581cadd883266587f927063c6e5b Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Wed, 15 Jun 2016 14:44:59 -0300 Subject: [PATCH 41/52] Edit Readme. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 80ec4b8..676d50c 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ 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/) or [see the changelog here](https://github.com/juanifioren/django-oidc-provider/blob/v0.2.x/CHANGELOG.md). +[Read docs for more info](http://django-oidc-provider.readthedocs.org/) or [see the changelog here](https://github.com/juanifioren/django-oidc-provider/blob/master/CHANGELOG.md). ## Contributing @@ -23,5 +23,5 @@ 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 and ensure everything is OK (read docs for how to test in all envs). * Send pull request to the specific version branch. From 4cc7474c1972cbb5b8f949ba477767d4be17f29a Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Thu, 16 Jun 2016 17:18:39 -0300 Subject: [PATCH 42/52] Add verbose name and description for scopes. --- docs/sections/settings.rst | 26 ++++++++++---- oidc_provider/lib/claims.py | 36 +++++++++++++++++-- oidc_provider/lib/endpoints/authorize.py | 14 ++++++++ .../templates/oidc_provider/authorize.html | 8 ++--- oidc_provider/views.py | 1 + 5 files changed, 73 insertions(+), 12 deletions(-) diff --git a/docs/sections/settings.rst b/docs/sections/settings.rst index 29b7837..4f07360 100644 --- a/docs/sections/settings.rst +++ b/docs/sections/settings.rst @@ -54,12 +54,23 @@ OpenID Connect Clients will use scope values to specify what access privileges a `Here `_ you have the standard scopes defined by the protocol. +You can create or modify scopes using: + +* ``info_scopename`` class property for setting the verbose name and description. +* ``scope_scopename`` method for returning some information related. + Check out an example of how to implement it:: + from django.utils.translation import ugettext as _ from oidc_provider.lib.claims import ScopeClaims class MyAppScopeClaims(ScopeClaims): + info_books = ( + _(u'Books'), # Verbose name of the scope. + _(u'Access to your books.'), # Description of the scope. + ) + def scope_books(self): # Here, for example, you can search books for this user. # self.user - Django user instance. @@ -72,11 +83,14 @@ Check out an example of how to implement it:: return dic -You can create our own scopes using the convention: + # If you want to change the description of the profile scope, you can redefine it. + info_profile = ( + _(u'Profile'), + _(u'Another description.'), + ) -``def scope_somename(self):`` - -If a field is empty or ``None`` will be cleaned from the response. +.. note:: + If a field is empty or ``None`` inside the dictionary your return on ``scope_scopename`` method, it will be cleaned from the response. OIDC_IDTOKEN_EXPIRE =================== @@ -93,9 +107,9 @@ 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 for your app into id_token. -The ``list`` or ``tuple`` is useful when You want to set multiple hooks, i.e. one for permissions and second for some special field. +The ``list`` or ``tuple`` is useful when you want to set multiple hooks, i.e. one for permissions and second for some special field. -The function receives a ``id_token`` dictionary and ``user`` instance +The function receives a ``id_token`` dictionary and ``user`` instance and returns it with additional fields. Default is:: diff --git a/oidc_provider/lib/claims.py b/oidc_provider/lib/claims.py index 660ae05..0e288e2 100644 --- a/oidc_provider/lib/claims.py +++ b/oidc_provider/lib/claims.py @@ -35,7 +35,6 @@ class ScopeClaims(object): scopes = [] for name in self.__class__.__dict__: - if name.startswith('scope_'): scope = name.split('scope_')[1] scopes.append(scope) @@ -56,13 +55,34 @@ class ScopeClaims(object): return aux_dic + @classmethod + def get_scopes_info(cls, scopes=[]): + scopes_info = [] + + for name in cls.__dict__: + if name.startswith('info_'): + scope_name = name.split('info_')[1] + if scope_name in scopes: + touple_info = getattr(cls, name) + scopes_info.append({ + 'scope': scope_name, + 'name': touple_info[0], + 'description': touple_info[1], + }) + + return scopes_info + class StandardScopeClaims(ScopeClaims): """ Based on OpenID Standard Claims. See: http://openid.net/specs/openid-connect-core-1_0.html#StandardClaims """ - + + info_profile = ( + _(u'Basic profile'), + _(u'Access to your basic information. Includes names, gender, birthdate and other information.'), + ) def scope_profile(self): dic = { 'name': getattr(self.userinfo, 'name', None), @@ -83,6 +103,10 @@ class StandardScopeClaims(ScopeClaims): return dic + info_email = ( + _(u'Email'), + _(u'Access to your email address.'), + ) def scope_email(self): dic = { 'email': getattr(self.user, 'email', None), @@ -91,6 +115,10 @@ class StandardScopeClaims(ScopeClaims): return dic + info_phone = ( + _(u'Phone number'), + _(u'Access to your phone number.'), + ) def scope_phone(self): dic = { 'phone_number': getattr(self.userinfo, 'phone_number', None), @@ -99,6 +127,10 @@ class StandardScopeClaims(ScopeClaims): return dic + info_address = ( + _(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 602ff89..01f49d8 100644 --- a/oidc_provider/lib/endpoints/authorize.py +++ b/oidc_provider/lib/endpoints/authorize.py @@ -7,6 +7,7 @@ except ImportError: from django.utils import timezone +from oidc_provider.lib.claims import StandardScopeClaims from oidc_provider.lib.errors import * from oidc_provider.lib.utils.params import * from oidc_provider.lib.utils.token import * @@ -207,3 +208,16 @@ class AuthorizeEndpoint(object): pass return value + + def get_scopes_information(self): + """ + Return a list with the description of all the scopes requested. + """ + scopes = StandardScopeClaims.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']: + del scopes[index] + + return scopes + scopes_extra diff --git a/oidc_provider/templates/oidc_provider/authorize.html b/oidc_provider/templates/oidc_provider/authorize.html index 95591b4..8d5e08d 100644 --- a/oidc_provider/templates/oidc_provider/authorize.html +++ b/oidc_provider/templates/oidc_provider/authorize.html @@ -3,15 +3,15 @@

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

- + {% csrf_token %} {{ hidden_inputs }}
    - {% for scope in params.scope %} -
  • {{ scope | capfirst }}
  • - {% endfor %} + {% for scope in scopes %} +
  • {{ scope.name }}
    {{ scope.description }}
  • + {% endfor %}
diff --git a/oidc_provider/views.py b/oidc_provider/views.py index 54229c7..417958e 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -73,6 +73,7 @@ class AuthorizeView(View): 'client': authorize.client, 'hidden_inputs': hidden_inputs, 'params': authorize.params, + 'scopes': authorize.get_scopes_information(), } return render(request, 'oidc_provider/authorize.html', context) From 6b9a25fdc567b33fdf6b6eaee445f18c4d721933 Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Thu, 16 Jun 2016 17:21:57 -0300 Subject: [PATCH 43/52] Edit CHANGELOG. --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1995c8d..68cefcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ All notable changes to this project will be documented in this file. ##### 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 From ab65aab0e14412dbb6d989ff27ceaabc412a2076 Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Tue, 21 Jun 2016 12:30:15 -0300 Subject: [PATCH 44/52] Bump version v0.3.5. --- CHANGELOG.md | 2 ++ setup.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68cefcd..25809df 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.3.5] - 2016-06-21 + ##### Added - Field date_given in UserConsent model. - Verbose names to all model fields. diff --git a/setup.py b/setup.py index 70ed75e..83a9601 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) setup( name='django-oidc-provider', - version='0.3.4', + version='0.3.5', packages=[ 'oidc_provider', 'oidc_provider/lib', 'oidc_provider/lib/endpoints', 'oidc_provider/lib/utils', 'oidc_provider/tests', 'oidc_provider/tests/app', From dc9ec1863e706e644606bb1514e4c61e356fcb99 Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Thu, 7 Jul 2016 12:50:27 -0300 Subject: [PATCH 45/52] Change setting OIDC_USERINFO. --- docs/sections/claims.rst | 49 ++++++++------------ docs/sections/settings.rst | 19 +++++++- oidc_provider/lib/claims.py | 59 ++++++++++++++---------- oidc_provider/lib/endpoints/authorize.py | 13 ++++-- oidc_provider/lib/utils/common.py | 10 ++-- oidc_provider/settings.py | 10 ++-- oidc_provider/tests/app/settings.py | 2 +- oidc_provider/tests/app/utils.py | 27 ++++------- oidc_provider/views.py | 8 ++-- 9 files changed, 99 insertions(+), 98 deletions(-) diff --git a/docs/sections/claims.rst b/docs/sections/claims.rst index 96e82c8..78160f7 100644 --- a/docs/sections/claims.rst +++ b/docs/sections/claims.rst @@ -12,17 +12,17 @@ List of all the attributes grouped by scopes: +--------------------+----------------+-----------------------+------------------------+ | profile | email | phone | address | +====================+================+=======================+========================+ -| name | email | phone_number | address_formatted | +| name | email | phone_number | formatted | +--------------------+----------------+-----------------------+------------------------+ -| given_name | email_verified | phone_number_verified | address_street_address | +| given_name | email_verified | phone_number_verified | street_address | +--------------------+----------------+-----------------------+------------------------+ -| family_name | | | address_locality | +| family_name | | | locality | +--------------------+----------------+-----------------------+------------------------+ -| middle_name | | | address_region | +| middle_name | | | region | +--------------------+----------------+-----------------------+------------------------+ -| nickname | | | address_postal_code | +| nickname | | | postal_code | +--------------------+----------------+-----------------------+------------------------+ -| preferred_username | | | address_country | +| preferred_username | | | country | +--------------------+----------------+-----------------------+------------------------+ | profile | | | | +--------------------+----------------+-----------------------+------------------------+ @@ -41,35 +41,22 @@ List of all the attributes grouped by scopes: | updated_at | | | | +--------------------+----------------+-----------------------+------------------------+ -Example using a django model:: +Somewhere in your Django ``settings.py``:: - from django.conf import settings - from django.db import models + OIDC_USERINFO = 'myproject.oidc_provider_settings.userinfo' - class UserInfo(models.Model): +Then create the function for the ``OIDC_USERINFO`` setting:: - GENDER_CHOICES = [ - ('F', 'Female'), - ('M', 'Male'), - ] + def userinfo(claims, user): - user = models.OneToOneField(settings.AUTH_USER_MODEL, primary_key=True) - - given_name = models.CharField(max_length=255, blank=True, null=True) - family_name = models.CharField(max_length=255, blank=True, null=True) - gender = models.CharField(max_length=100, choices=GENDER_CHOICES, null=True) - birthdate = models.DateField(null=True) - updated_at = models.DateTimeField(auto_now=True, null=True) + claims['name'] = '{0} {1}'.format(user.first_name, user.last_name) + claims['given_name'] = user.first_name + claims['family_name'] = user.last_name + claims['email'] = user.email + claims['address']['street_address'] = '...' - email_verified = models.NullBooleanField(default=False) + return claims - phone_number = models.CharField(max_length=255, blank=True, null=True) - phone_number_verified = models.NullBooleanField(default=False) - - address_locality = models.CharField(max_length=255, blank=True, null=True) - address_country = models.CharField(max_length=255, blank=True, null=True) - - @classmethod - def get_by_user(cls, user): - return cls.objects.get(user=user) +.. note:: + Please **DO NOT** add extra keys or delete the existing ones in the ``claims`` dict. If you want to add extra claims to some scopes you can use the ``OIDC_EXTRA_SCOPE_CLAIMS`` setting. diff --git a/docs/sections/settings.rst b/docs/sections/settings.rst index 4f07360..e51194e 100644 --- a/docs/sections/settings.rst +++ b/docs/sections/settings.rst @@ -162,4 +162,21 @@ Expressed in seconds. Default is ``60*60``. OIDC_USERINFO ============= -OPTIONAL. ``str``. A string with the location of your class. Read **Standard Claims** section. +OPTIONAL. ``str``. A string with the location of your function. Read **Standard Claims** section. + +The function receives a ``claims`` dictionary with all the standard claims and ``user`` instance. Must returns the ``claims`` dict again. + +Example usage:: + + def userinfo(claims, user): + + claims['name'] = '{0} {1}'.format(user.first_name, user.last_name) + claims['given_name'] = user.first_name + claims['family_name'] = user.last_name + claims['email'] = user.email + claims['address']['street_address'] = '...' + + return claims + +.. note:: + Please **DO NOT** add extra keys or delete the existing ones in the ``claims`` dict. If you want to add extra claims to some scopes you can use the ``OIDC_EXTRA_SCOPE_CLAIMS`` setting. diff --git a/oidc_provider/lib/claims.py b/oidc_provider/lib/claims.py index 0e288e2..4330ee3 100644 --- a/oidc_provider/lib/claims.py +++ b/oidc_provider/lib/claims.py @@ -3,11 +3,20 @@ from django.utils.translation import ugettext as _ 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': '', }, +} + + class ScopeClaims(object): def __init__(self, user, scopes): self.user = user - self.userinfo = settings.get('OIDC_USERINFO', import_str=True).get_by_user(self.user) + self.userinfo = settings.get('OIDC_USERINFO', import_str=True)(STANDARD_CLAIMS, self.user) self.scopes = scopes def create_response_dic(self): @@ -85,20 +94,20 @@ class StandardScopeClaims(ScopeClaims): ) def scope_profile(self): dic = { - 'name': getattr(self.userinfo, 'name', None), - 'given_name': getattr(self.userinfo, 'given_name', None), - 'family_name': getattr(self.userinfo, 'family_name', None), - 'middle_name': getattr(self.userinfo, 'middle_name', None), - 'nickname': getattr(self.userinfo, 'nickname', None), - 'preferred_username': getattr(self.userinfo, 'preferred_username', None), - 'profile': getattr(self.userinfo, 'profile', None), - 'picture': getattr(self.userinfo, 'picture', None), - 'website': getattr(self.userinfo, 'website', None), - 'gender': getattr(self.userinfo, 'gender', None), - 'birthdate': getattr(self.userinfo, 'birthdate', None), - 'zoneinfo': getattr(self.userinfo, 'zoneinfo', None), - 'locale': getattr(self.userinfo, 'locale', None), - 'updated_at': getattr(self.userinfo, 'updated_at', None), + 'name': self.userinfo.get('name'), + 'given_name': self.userinfo.get('given_name'), + 'family_name': self.userinfo.get('family_name'), + 'middle_name': self.userinfo.get('middle_name'), + 'nickname': self.userinfo.get('nickname'), + 'preferred_username': self.userinfo.get('preferred_username'), + 'profile': self.userinfo.get('profile'), + 'picture': self.userinfo.get('picture'), + 'website': self.userinfo.get('website'), + 'gender': self.userinfo.get('gender'), + 'birthdate': self.userinfo.get('birthdate'), + 'zoneinfo': self.userinfo.get('zoneinfo'), + 'locale': self.userinfo.get('locale'), + 'updated_at': self.userinfo.get('updated_at'), } return dic @@ -109,8 +118,8 @@ class StandardScopeClaims(ScopeClaims): ) def scope_email(self): dic = { - 'email': getattr(self.user, 'email', None), - 'email_verified': getattr(self.userinfo, 'email_verified', None), + 'email': self.userinfo.get('email'), + 'email_verified': self.userinfo.get('email_verified'), } return dic @@ -121,8 +130,8 @@ class StandardScopeClaims(ScopeClaims): ) def scope_phone(self): dic = { - 'phone_number': getattr(self.userinfo, 'phone_number', None), - 'phone_number_verified': getattr(self.userinfo, 'phone_number_verified', None), + 'phone_number': self.userinfo.get('phone_number'), + 'phone_number_verified': self.userinfo.get('phone_number_verified'), } return dic @@ -134,12 +143,12 @@ class StandardScopeClaims(ScopeClaims): def scope_address(self): dic = { 'address': { - 'formatted': getattr(self.userinfo, 'address_formatted', None), - 'street_address': getattr(self.userinfo, 'address_street_address', None), - 'locality': getattr(self.userinfo, 'address_locality', None), - 'region': getattr(self.userinfo, 'address_region', None), - 'postal_code': getattr(self.userinfo, 'address_postal_code', None), - 'country': getattr(self.userinfo, 'address_country', None), + 'formatted': self.userinfo.get('address', {}).get('formatted'), + 'street_address': self.userinfo.get('address', {}).get('street_address'), + 'locality': self.userinfo.get('address', {}).get('locality'), + 'region': self.userinfo.get('address', {}).get('region'), + 'postal_code': self.userinfo.get('address', {}).get('postal_code'), + 'country': self.userinfo.get('address', {}).get('country'), } } diff --git a/oidc_provider/lib/endpoints/authorize.py b/oidc_provider/lib/endpoints/authorize.py index 01f49d8..d2d1951 100644 --- a/oidc_provider/lib/endpoints/authorize.py +++ b/oidc_provider/lib/endpoints/authorize.py @@ -214,10 +214,13 @@ class AuthorizeEndpoint(object): Return a list with the description of all the scopes requested. """ scopes = StandardScopeClaims.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']: - del scopes[index] + if settings.get('OIDC_EXTRA_SCOPE_CLAIMS'): + 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']: + del scopes[index] + else: + scopes_extra = [] return scopes + scopes_extra diff --git a/oidc_provider/lib/utils/common.py b/oidc_provider/lib/utils/common.py index 78305c6..6b625e3 100644 --- a/oidc_provider/lib/utils/common.py +++ b/oidc_provider/lib/utils/common.py @@ -45,14 +45,12 @@ def get_issuer(site_url=None, request=None): return issuer -class DefaultUserInfo(object): +def default_userinfo(claims, user): """ - Default class for setting OIDC_USERINFO. + Default function for setting OIDC_USERINFO. + `claims` is a dict that contains all the OIDC standard claims. """ - - @classmethod - def get_by_user(cls, user): - return None + return claims def default_sub_generator(user): diff --git a/oidc_provider/settings.py b/oidc_provider/settings.py index 00f2c70..8e16aab 100644 --- a/oidc_provider/settings.py +++ b/oidc_provider/settings.py @@ -41,9 +41,9 @@ class DefaultSettings(object): def OIDC_EXTRA_SCOPE_CLAIMS(self): """ OPTIONAL. A string with the location of your class. - Used to add extra scopes specific for your app. + Used to add extra scopes specific for your app. """ - return 'oidc_provider.lib.claims.ScopeClaims' + return None @property def OIDC_IDTOKEN_EXPIRE(self): @@ -95,10 +95,10 @@ class DefaultSettings(object): @property def OIDC_USERINFO(self): """ - OPTIONAL. A string with the location of your class. - Used to add extra scopes specific for your app. + OPTIONAL. A string with the location of your function. + Used to populate standard claims with your user information. """ - return 'oidc_provider.lib.utils.common.DefaultUserInfo' + return 'oidc_provider.lib.utils.common.default_userinfo' @property def OIDC_IDTOKEN_PROCESSING_HOOK(self): diff --git a/oidc_provider/tests/app/settings.py b/oidc_provider/tests/app/settings.py index 281b28c..113f43c 100644 --- a/oidc_provider/tests/app/settings.py +++ b/oidc_provider/tests/app/settings.py @@ -58,4 +58,4 @@ USE_TZ = True # OIDC Provider settings. SITE_URL = 'http://localhost:8000' -OIDC_USERINFO = 'oidc_provider.tests.app.utils.FakeUserInfo' +OIDC_USERINFO = 'oidc_provider.tests.app.utils.userinfo' diff --git a/oidc_provider/tests/app/utils.py b/oidc_provider/tests/app/utils.py index 684dac8..616b130 100644 --- a/oidc_provider/tests/app/utils.py +++ b/oidc_provider/tests/app/utils.py @@ -73,27 +73,16 @@ def is_code_valid(url, user, client): return is_code_ok -class FakeUserInfo(object): +def userinfo(claims, user): """ - Fake class for setting OIDC_USERINFO. + Fake function for setting OIDC_USERINFO. """ - - given_name = 'John' - family_name = 'Doe' - nickname = 'johndoe' - website = 'http://johndoe.com' - - phone_number = '+49-89-636-48018' - phone_number_verified = True - - address_street_address = 'Evergreen 742' - address_locality = 'Glendive' - address_region = 'Montana' - address_country = 'United States' - - @classmethod - def get_by_user(cls, user): - return cls() + claims['given_name'] = 'John' + claims['family_name'] = 'Doe' + claims['name'] = '{0} {1}'.format(claims['given_name'], claims['family_name']) + claims['email'] = user.email + claims['address']['country'] = 'Argentina' + return claims def fake_sub_generator(user): diff --git a/oidc_provider/views.py b/oidc_provider/views.py index 417958e..a22ca17 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -157,13 +157,11 @@ def userinfo(request, *args, **kwargs): } standard_claims = StandardScopeClaims(token.user, token.scope) - dic.update(standard_claims.create_response_dic()) - extra_claims = settings.get('OIDC_EXTRA_SCOPE_CLAIMS', import_str=True)( - token.user, token.scope) - - dic.update(extra_claims.create_response_dic()) + if settings.get('OIDC_EXTRA_SCOPE_CLAIMS'): + extra_claims = settings.get('OIDC_EXTRA_SCOPE_CLAIMS', import_str=True)(token.user, token.scope) + dic.update(extra_claims.create_response_dic()) response = JsonResponse(dic, status=200) response['Cache-Control'] = 'no-store' From 5766f814d370480db041954e51b5e3820b534863 Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Thu, 7 Jul 2016 12:52:32 -0300 Subject: [PATCH 46/52] Bump version 0.3.6. --- CHANGELOG.md | 5 +++++ setup.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25809df..5bfc26b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. ### [Unreleased] +### [0.3.6] - 2016-07-07 + +##### Changed +- OIDC_USERINFO setting. + ### [0.3.5] - 2016-06-21 ##### Added diff --git a/setup.py b/setup.py index 83a9601..6fd3783 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) setup( name='django-oidc-provider', - version='0.3.5', + version='0.3.6', packages=[ 'oidc_provider', 'oidc_provider/lib', 'oidc_provider/lib/endpoints', 'oidc_provider/lib/utils', 'oidc_provider/tests', 'oidc_provider/tests/app', From b70007e4a9dd56abd1471b60bb0411f795785eb3 Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Tue, 26 Jul 2016 17:19:33 -0300 Subject: [PATCH 47/52] Add ES and FR initial translations. --- oidc_provider/locale/es/LC_MESSAGES/django.mo | Bin 0 -> 421 bytes oidc_provider/locale/es/LC_MESSAGES/django.po | 185 ++++++++++++++++++ oidc_provider/locale/fr/LC_MESSAGES/django.mo | Bin 0 -> 420 bytes oidc_provider/locale/fr/LC_MESSAGES/django.po | 185 ++++++++++++++++++ 4 files changed, 370 insertions(+) create mode 100644 oidc_provider/locale/es/LC_MESSAGES/django.mo create mode 100644 oidc_provider/locale/es/LC_MESSAGES/django.po create mode 100644 oidc_provider/locale/fr/LC_MESSAGES/django.mo create mode 100644 oidc_provider/locale/fr/LC_MESSAGES/django.po diff --git a/oidc_provider/locale/es/LC_MESSAGES/django.mo b/oidc_provider/locale/es/LC_MESSAGES/django.mo new file mode 100644 index 0000000000000000000000000000000000000000..4ddd099bdc5be9ddab890c9c6eb8141559e349a8 GIT binary patch literal 421 zcmYL^-%i3X6o)Z-wM(zPsEHREJvcA{EX-J0!NvW{7$kaEq0Z@Om$n#v5MR${u~Q=_ z`O?$$JMFJu`+J`)TRWBm%aLW*a%O32V0pENmv(KO|4Nm7;Y$O-3Km@HQVNVxGQJ~I ziWW4R1xf5=yq3y9q>FL|H=9C3E-`Y_B!{u$%-A9Dnc*09-A)hOK6HDi)5lGJ;ks6Y zjK7u5<$vBiB^g8s_ypZ1I0{Z5l9)On(*|@+b_itFkRsv2|?JD2V9xf1S4LjHNJK*ylcKo7?brb6!`h z6hpLpW=eD8&GQKiwq6tKi7TLDDOaT^Fd96T#!0J, YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2016-07-26 17:16-0300\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: lib/claims.py:92 +msgid "Basic profile" +msgstr "" + +#: lib/claims.py:93 +msgid "" +"Access to your basic information. Includes names, gender, birthdate and " +"other information." +msgstr "" + +#: lib/claims.py:116 +msgid "Email" +msgstr "" + +#: lib/claims.py:117 +msgid "Access to your email address." +msgstr "" + +#: lib/claims.py:128 +msgid "Phone number" +msgstr "" + +#: lib/claims.py:129 +msgid "Access to your phone number." +msgstr "" + +#: lib/claims.py:140 +msgid "Address information" +msgstr "" + +#: lib/claims.py:141 +msgid "" +"Access to your address. Includes country, locality, street and other " +"information." +msgstr "" + +#: models.py:29 +msgid "Name" +msgstr "" + +#: models.py:30 +msgid "Client Type" +msgstr "" + +#: models.py:30 +msgid "" +"Confidential clients are capable of maintaining the confidentiality " +"of their credentials. Public clients are incapable." +msgstr "" + +#: models.py:31 +msgid "Client ID" +msgstr "" + +#: models.py:32 +msgid "Client SECRET" +msgstr "" + +#: models.py:33 +msgid "Response Type" +msgstr "" + +#: models.py:34 +msgid "JWT Algorithm" +msgstr "" + +#: models.py:35 +msgid "Date Created" +msgstr "" + +#: models.py:37 +msgid "Redirect URIs" +msgstr "" + +#: models.py:37 +msgid "Enter each URI on a new line." +msgstr "" + +#: models.py:40 models.py:65 +msgid "Client" +msgstr "" + +#: models.py:41 +msgid "Clients" +msgstr "" + +#: models.py:64 +msgid "User" +msgstr "" + +#: models.py:66 +msgid "Expiration Date" +msgstr "" + +#: models.py:67 +msgid "Scopes" +msgstr "" + +#: models.py:92 +msgid "Code" +msgstr "" + +#: models.py:93 +msgid "Nonce" +msgstr "" + +#: models.py:94 +msgid "Is Authentication?" +msgstr "" + +#: models.py:95 +msgid "Code Challenge" +msgstr "" + +#: models.py:96 +msgid "Code Challenge Method" +msgstr "" + +#: models.py:99 +msgid "Authorization Code" +msgstr "" + +#: models.py:100 +msgid "Authorization Codes" +msgstr "" + +#: models.py:105 +msgid "Access Token" +msgstr "" + +#: models.py:106 +msgid "Refresh Token" +msgstr "" + +#: models.py:107 +msgid "ID Token" +msgstr "" + +#: models.py:117 +msgid "Token" +msgstr "" + +#: models.py:118 +msgid "Tokens" +msgstr "" + +#: models.py:123 +msgid "Date Given" +msgstr "" + +#: models.py:131 +msgid "Key" +msgstr "" + +#: models.py:131 +msgid "Paste your private RSA Key here." +msgstr "" + +#: models.py:134 +msgid "RSA Key" +msgstr "" + +#: models.py:135 +msgid "RSA Keys" +msgstr "" diff --git a/oidc_provider/locale/fr/LC_MESSAGES/django.mo b/oidc_provider/locale/fr/LC_MESSAGES/django.mo new file mode 100644 index 0000000000000000000000000000000000000000..1d3f2e81620ffe3ed41d3bed0d554a2023e06fab GIT binary patch literal 420 zcmYL^O;5rw9EUM_+R?Lz9z1CDj~hn7@)9a5xVRTG28rG()HyQRr7cE3h+ogoVy8xa z$&9A`DSXB8vJnB0)GJzMs&R7b=g?;X$vb_y|H&<23 z3eiKWSEe*KgGDxh?%u0wy>JCotmL{7IY!;*!Z@>Xr&tLSskRy;QT;6Spw&aa)4`cQ KL*$+JoW?i0;B+wn literal 0 HcmV?d00001 diff --git a/oidc_provider/locale/fr/LC_MESSAGES/django.po b/oidc_provider/locale/fr/LC_MESSAGES/django.po new file mode 100644 index 0000000..3b33387 --- /dev/null +++ b/oidc_provider/locale/fr/LC_MESSAGES/django.po @@ -0,0 +1,185 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2016-07-26 17:15-0300\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: lib/claims.py:92 +msgid "Basic profile" +msgstr "" + +#: lib/claims.py:93 +msgid "" +"Access to your basic information. Includes names, gender, birthdate and " +"other information." +msgstr "" + +#: lib/claims.py:116 +msgid "Email" +msgstr "" + +#: lib/claims.py:117 +msgid "Access to your email address." +msgstr "" + +#: lib/claims.py:128 +msgid "Phone number" +msgstr "" + +#: lib/claims.py:129 +msgid "Access to your phone number." +msgstr "" + +#: lib/claims.py:140 +msgid "Address information" +msgstr "" + +#: lib/claims.py:141 +msgid "" +"Access to your address. Includes country, locality, street and other " +"information." +msgstr "" + +#: models.py:29 +msgid "Name" +msgstr "" + +#: models.py:30 +msgid "Client Type" +msgstr "" + +#: models.py:30 +msgid "" +"Confidential clients are capable of maintaining the confidentiality " +"of their credentials. Public clients are incapable." +msgstr "" + +#: models.py:31 +msgid "Client ID" +msgstr "" + +#: models.py:32 +msgid "Client SECRET" +msgstr "" + +#: models.py:33 +msgid "Response Type" +msgstr "" + +#: models.py:34 +msgid "JWT Algorithm" +msgstr "" + +#: models.py:35 +msgid "Date Created" +msgstr "" + +#: models.py:37 +msgid "Redirect URIs" +msgstr "" + +#: models.py:37 +msgid "Enter each URI on a new line." +msgstr "" + +#: models.py:40 models.py:65 +msgid "Client" +msgstr "" + +#: models.py:41 +msgid "Clients" +msgstr "" + +#: models.py:64 +msgid "User" +msgstr "" + +#: models.py:66 +msgid "Expiration Date" +msgstr "" + +#: models.py:67 +msgid "Scopes" +msgstr "" + +#: models.py:92 +msgid "Code" +msgstr "" + +#: models.py:93 +msgid "Nonce" +msgstr "" + +#: models.py:94 +msgid "Is Authentication?" +msgstr "" + +#: models.py:95 +msgid "Code Challenge" +msgstr "" + +#: models.py:96 +msgid "Code Challenge Method" +msgstr "" + +#: models.py:99 +msgid "Authorization Code" +msgstr "" + +#: models.py:100 +msgid "Authorization Codes" +msgstr "" + +#: models.py:105 +msgid "Access Token" +msgstr "" + +#: models.py:106 +msgid "Refresh Token" +msgstr "" + +#: models.py:107 +msgid "ID Token" +msgstr "" + +#: models.py:117 +msgid "Token" +msgstr "" + +#: models.py:118 +msgid "Tokens" +msgstr "" + +#: models.py:123 +msgid "Date Given" +msgstr "" + +#: models.py:131 +msgid "Key" +msgstr "" + +#: models.py:131 +msgid "Paste your private RSA Key here." +msgstr "" + +#: models.py:134 +msgid "RSA Key" +msgstr "" + +#: models.py:135 +msgid "RSA Keys" +msgstr "" From c29514ca642401c715935f6ef65dd024be49b0f9 Mon Sep 17 00:00:00 2001 From: Florent Jouatte Date: Thu, 28 Jul 2016 10:55:06 +0200 Subject: [PATCH 48/52] add french translation (not over) --- oidc_provider/locale/fr/LC_MESSAGES/django.po | 76 ++++++++++--------- 1 file changed, 40 insertions(+), 36 deletions(-) diff --git a/oidc_provider/locale/fr/LC_MESSAGES/django.po b/oidc_provider/locale/fr/LC_MESSAGES/django.po index 3b33387..c27bac6 100644 --- a/oidc_provider/locale/fr/LC_MESSAGES/django.po +++ b/oidc_provider/locale/fr/LC_MESSAGES/django.po @@ -20,57 +20,61 @@ msgstr "" #: lib/claims.py:92 msgid "Basic profile" -msgstr "" +msgstr "Profil de base" #: lib/claims.py:93 msgid "" "Access to your basic information. Includes names, gender, birthdate and " "other information." -msgstr "" +msgstr "Accès à vos informations de base. Comprend vos noms, genre, " +"date de naissance et d'autres informations" #: lib/claims.py:116 msgid "Email" -msgstr "" +msgstr "Courriel" #: lib/claims.py:117 msgid "Access to your email address." -msgstr "" +msgstr "Accès à votre adresse email" #: lib/claims.py:128 msgid "Phone number" -msgstr "" +msgstr "Numéro de téléphone" #: lib/claims.py:129 msgid "Access to your phone number." -msgstr "" +msgstr "Accès à votre numéro de téléphone" #: lib/claims.py:140 msgid "Address information" -msgstr "" +msgstr "Informations liées à votre adresse" #: lib/claims.py:141 msgid "" "Access to your address. Includes country, locality, street and other " "information." -msgstr "" +msgstr "Accès à votre adresse. Comprend le pays, la ville, la rue " +"et d'autres informations" #: models.py:29 msgid "Name" -msgstr "" +msgstr "Nom" #: models.py:30 msgid "Client Type" -msgstr "" +msgstr "Type de client" #: models.py:30 msgid "" "Confidential clients are capable of maintaining the confidentiality " "of their credentials. Public clients are incapable." msgstr "" +"Confidentiel les clients sont capable de maintenir la confidentialité " +" des paramètres de connexion. Public les clients n'en sont pas capable" #: models.py:31 msgid "Client ID" -msgstr "" +msgstr "Identifiant client" #: models.py:32 msgid "Client SECRET" @@ -78,55 +82,55 @@ msgstr "" #: models.py:33 msgid "Response Type" -msgstr "" +msgstr "Type de réponse" #: models.py:34 msgid "JWT Algorithm" -msgstr "" +msgstr "Algorythme JWT" #: models.py:35 msgid "Date Created" -msgstr "" +msgstr "Date de création" #: models.py:37 msgid "Redirect URIs" -msgstr "" +msgstr "URIs utilisée pour la redirection" #: models.py:37 msgid "Enter each URI on a new line." -msgstr "" +msgstr "Entrez chaque URI à la ligne" #: models.py:40 models.py:65 msgid "Client" -msgstr "" +msgstr "Client" #: models.py:41 msgid "Clients" -msgstr "" +msgstr "Clients" #: models.py:64 msgid "User" -msgstr "" +msgstr "Utilisateur" #: models.py:66 msgid "Expiration Date" -msgstr "" +msgstr "Date d'expiration" #: models.py:67 msgid "Scopes" -msgstr "" +msgstr "Portées" #: models.py:92 msgid "Code" -msgstr "" +msgstr "Code" #: models.py:93 msgid "Nonce" -msgstr "" +msgstr "Valeur de circonstance" #: models.py:94 msgid "Is Authentication?" -msgstr "" +msgstr "Est authentifié ?" #: models.py:95 msgid "Code Challenge" @@ -138,48 +142,48 @@ msgstr "" #: models.py:99 msgid "Authorization Code" -msgstr "" +msgstr "Code d'authorisation" #: models.py:100 msgid "Authorization Codes" -msgstr "" +msgstr "Codes d'authorisation" #: models.py:105 msgid "Access Token" -msgstr "" +msgstr "Jeton d'accès" #: models.py:106 msgid "Refresh Token" -msgstr "" +msgstr "Jeton de rafraichissement" #: models.py:107 msgid "ID Token" -msgstr "" +msgstr "Identifiant du jeton" #: models.py:117 msgid "Token" -msgstr "" +msgstr "Jeton" #: models.py:118 msgid "Tokens" -msgstr "" +msgstr "Jetons" #: models.py:123 msgid "Date Given" -msgstr "" +msgstr "Date donnée" #: models.py:131 msgid "Key" -msgstr "" +msgstr "Clé" #: models.py:131 msgid "Paste your private RSA Key here." -msgstr "" +msgstr "Collez votre clé privée RSA ici." #: models.py:134 msgid "RSA Key" -msgstr "" +msgstr "Clé RSA" #: models.py:135 msgid "RSA Keys" -msgstr "" +msgstr "Clés RSA" From 7391a58b690137d1fc57e0a4c38753a8cf325341 Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Thu, 28 Jul 2016 16:35:12 -0300 Subject: [PATCH 49/52] Tox: Remove previously installed package. --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 2b107d0..740dae2 100644 --- a/tox.ini +++ b/tox.ini @@ -13,6 +13,7 @@ deps = mock commands = + pip uninstall --yes django-oidc-provider pip install -e . coverage run --omit=.tox/*,oidc_provider/tests/* {envbindir}/django-admin.py test oidc_provider --settings=oidc_provider.tests.app.settings From 6e771ad5eeb5347f8562758423b28590de80ccd8 Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Thu, 28 Jul 2016 17:17:23 -0300 Subject: [PATCH 50/52] Edit tox supporting running single test. --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 740dae2..48d8589 100644 --- a/tox.ini +++ b/tox.ini @@ -15,7 +15,7 @@ deps = commands = pip uninstall --yes django-oidc-provider pip install -e . - coverage run --omit=.tox/*,oidc_provider/tests/* {envbindir}/django-admin.py test oidc_provider --settings=oidc_provider.tests.app.settings + coverage run --omit=.tox/*,oidc_provider/tests/* {envbindir}/django-admin.py test {posargs:oidc_provider} --settings=oidc_provider.tests.app.settings [testenv:clean] From 3f12db69303245393c9a1f16d074ae4c81b92061 Mon Sep 17 00:00:00 2001 From: Ignacio Fiorentino Date: Thu, 28 Jul 2016 17:18:11 -0300 Subject: [PATCH 51/52] Add doc for tox. --- docs/sections/contribute.rst | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/sections/contribute.rst b/docs/sections/contribute.rst index fd153c7..2811551 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 and ensure everything is OK (read docs for how to test in all envs). * Send pull request to the specific version branch. Running Tests @@ -15,8 +15,12 @@ Running Tests Use `tox `_ for running tests in each of the environments, also to run coverage among:: + # Run all tests. $ tox + # Run a particular test file with Python 2.7 and Django 1.9. + $ tox -e py27-django19 oidc_provider.tests.test_authorize_endpoint + If you have a Django project properly configured with the package. Then just run tests as normal:: $ python manage.py test --settings oidc_provider.tests.app.settings oidc_provider From f4c27ed28d54830bd760cf20f58dd82b7e3b0dc2 Mon Sep 17 00:00:00 2001 From: Florent Jouatte Date: Fri, 29 Jul 2016 09:07:25 +0200 Subject: [PATCH 52/52] tiny translation --- oidc_provider/locale/fr/LC_MESSAGES/django.po | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oidc_provider/locale/fr/LC_MESSAGES/django.po b/oidc_provider/locale/fr/LC_MESSAGES/django.po index c27bac6..2b940bb 100644 --- a/oidc_provider/locale/fr/LC_MESSAGES/django.po +++ b/oidc_provider/locale/fr/LC_MESSAGES/django.po @@ -78,7 +78,7 @@ msgstr "Identifiant client" #: models.py:32 msgid "Client SECRET" -msgstr "" +msgstr "Code secret client" #: models.py:33 msgid "Response Type"