diff --git a/.gitignore b/.gitignore index fa1d0dc..2c6f875 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ src/ .venv .idea docs/_build/ +.eggs/ +.python-version diff --git a/.travis.yml b/.travis.yml index d947e99..2245af5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,12 +7,14 @@ env: - DJANGO=1.7 - DJANGO=1.8 - DJANGO=1.9 + - DJANGO=1.10 matrix: exclude: - python: "3.5" env: DJANGO=1.7 install: - - pip install -q django==$DJANGO - - pip install -e . + - pip install tox coveralls script: - - PYTHONPATH=$PYTHONPATH:$PWD django-admin.py test oidc_provider --settings=oidc_provider.tests.app.settings + - tox -e py${TRAVIS_PYTHON_VERSION//[.]/}-django${DJANGO//[.]/} +after_success: + - coveralls diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bfc26b..a45c483 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. ### [Unreleased] +### [0.3.7] - 2016-08-31 + +##### Added +- Support for Django 1.10. +- Initial translation files (ES, FR). +- Support for at_hash parameter. + +##### Fixed +- Empty address dict in userinfo response. + ### [0.3.6] - 2016-07-07 ##### Changed diff --git a/MANIFEST.in b/MANIFEST.in index 07cb30b..de708f8 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ include LICENSE -include README.rst +include README.md recursive-include oidc_provider/templates * recursive-include oidc_provider/tests/templates * diff --git a/README.md b/README.md index 676d50c..79a04c3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Python Versions](https://img.shields.io/pypi/pyversions/django-oidc-provider.svg)](https://pypi.python.org/pypi/django-oidc-provider) [![PyPI Versions](https://img.shields.io/pypi/v/django-oidc-provider.svg)](https://pypi.python.org/pypi/django-oidc-provider) -[![Travis](https://travis-ci.org/juanifioren/django-oidc-provider.svg?branch=master)](https://travis-ci.org/juanifioren/django-oidc-provider) +[![Travis](https://travis-ci.org/juanifioren/django-oidc-provider.svg?branch=develop)](https://travis-ci.org/juanifioren/django-oidc-provider) [![PyPI Downloads](https://img.shields.io/pypi/dm/django-oidc-provider.svg)](https://pypi.python.org/pypi/django-oidc-provider) ## About OpenID @@ -24,4 +24,4 @@ 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). -* Send pull request to the specific version branch. +* Send pull request to the `develop` branch. diff --git a/docs/conf.py b/docs/conf.py index bb4f14f..e3760f6 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -106,7 +106,7 @@ todo_include_todos = False # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'sphinx_rtd_theme' +html_theme = 'alabaster' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the diff --git a/docs/sections/contribute.rst b/docs/sections/contribute.rst index 2811551..f7a07e9 100644 --- a/docs/sections/contribute.rst +++ b/docs/sections/contribute.rst @@ -18,12 +18,11 @@ Use `tox `_ for running tests in each of the e # 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 + # Run with Python 2.7 and Django 1.9. + $ tox -e py27-django19 -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 + # Run single test file. + $ python runtests.py oidc_provider.tests.test_authorize_endpoint Also tests run on every commit to the project, we use `travis `_ for this. diff --git a/docs/sections/installation.rst b/docs/sections/installation.rst index 3c4f811..59b5e10 100644 --- a/docs/sections/installation.rst +++ b/docs/sections/installation.rst @@ -7,7 +7,7 @@ Requirements ============ * Python: ``2.7`` ``3.4`` ``3.5`` -* Django: ``1.7`` ``1.8`` ``1.9`` +* Django: ``1.7`` ``1.8`` ``1.9`` ``1.10`` Quick Installation ================== diff --git a/oidc_provider/lib/claims.py b/oidc_provider/lib/claims.py index 4330ee3..16e3919 100644 --- a/oidc_provider/lib/claims.py +++ b/oidc_provider/lib/claims.py @@ -60,8 +60,11 @@ class ScopeClaims(object): if value is None or value == '': del aux_dic[key] elif type(value) is dict: - aux_dic[key] = self._clean_dic(value) - + cleaned_dict = self._clean_dic(value) + if not cleaned_dict: + del aux_dic[key] + continue + aux_dic[key] = cleaned_dict return aux_dic @classmethod diff --git a/oidc_provider/lib/endpoints/authorize.py b/oidc_provider/lib/endpoints/authorize.py index d2d1951..ca4a584 100644 --- a/oidc_provider/lib/endpoints/authorize.py +++ b/oidc_provider/lib/endpoints/authorize.py @@ -1,3 +1,4 @@ +from datetime import timedelta import logging try: from urllib import urlencode @@ -8,10 +9,22 @@ 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 * -from oidc_provider.models import * +from oidc_provider.lib.errors import ( + AuthorizeError, + ClientIdError, + RedirectUriError, +) +from oidc_provider.lib.utils.params import Params +from oidc_provider.lib.utils.token import ( + create_code, + create_id_token, + create_token, + encode_id_token, +) +from oidc_provider.models import ( + Client, + UserConsent, +) from oidc_provider import settings @@ -121,35 +134,41 @@ class AuthorizeEndpoint(object): query_params['state'] = self.params.state if self.params.state else '' elif self.grant_type == 'implicit': - # We don't need id_token if it's an OAuth2 request. - if self.is_authentication: - id_token_dic = create_id_token( - user=self.request.user, - aud=self.client.client_id, - nonce=self.params.nonce, - request=self.request) - query_fragment['id_token'] = encode_id_token(id_token_dic, self.client) - else: - id_token_dic = {} - token = create_token( user=self.request.user, client=self.client, - id_token_dic=id_token_dic, scope=self.params.scope) - # Store the token. - token.save() - - query_fragment['token_type'] = 'bearer' - # TODO: Create setting 'OIDC_TOKEN_EXPIRE'. - query_fragment['expires_in'] = 60 * 10 - # Check if response_type is an OpenID request with value 'id_token token' # or it's an OAuth2 Implicit Flow request. if self.params.response_type in ['id_token token', 'token']: query_fragment['access_token'] = token.access_token + # We don't need id_token if it's an OAuth2 request. + if self.is_authentication: + kwargs = { + "user": self.request.user, + "aud": self.client.client_id, + "nonce": self.params.nonce, + "request": self.request + } + # Include at_hash when access_token is being returned. + if 'access_token' in query_fragment: + kwargs['at_hash'] = token.at_hash + id_token_dic = create_id_token(**kwargs) + query_fragment['id_token'] = encode_id_token(id_token_dic, self.client) + token.id_token = id_token_dic + else: + id_token_dic = {} + + # Store the token. + token.id_token = id_token_dic + token.save() + + query_fragment['token_type'] = 'bearer' + # TODO: Create setting 'OIDC_TOKEN_EXPIRE'. + query_fragment['expires_in'] = 60 * 10 + query_fragment['state'] = self.params.state if self.params.state else '' except Exception as error: diff --git a/oidc_provider/lib/endpoints/token.py b/oidc_provider/lib/endpoints/token.py index 200cb2c..a10c508 100644 --- a/oidc_provider/lib/endpoints/token.py +++ b/oidc_provider/lib/endpoints/token.py @@ -9,10 +9,20 @@ except ImportError: from django.http import JsonResponse -from oidc_provider.lib.errors import * -from oidc_provider.lib.utils.params import * -from oidc_provider.lib.utils.token import * -from oidc_provider.models import * +from oidc_provider.lib.errors import ( + TokenError, +) +from oidc_provider.lib.utils.params import Params +from oidc_provider.lib.utils.token import ( + create_id_token, + create_token, + encode_id_token, +) +from oidc_provider.models import ( + Client, + Code, + Token, +) from oidc_provider import settings @@ -131,21 +141,22 @@ class TokenEndpoint(object): return self.create_refresh_response_dic() def create_code_response_dic(self): + token = create_token( + user=self.code.user, + client=self.code.client, + scope=self.code.scope) + if self.code.is_authentication: id_token_dic = create_id_token( user=self.code.user, aud=self.client.client_id, nonce=self.code.nonce, + at_hash=token.at_hash, request=self.request, ) else: id_token_dic = {} - - token = create_token( - user=self.code.user, - client=self.code.client, - id_token_dic=id_token_dic, - scope=self.code.scope) + token.id_token = id_token_dic # Store the token. token.save() @@ -164,22 +175,23 @@ class TokenEndpoint(object): return dic def create_refresh_response_dic(self): + token = create_token( + user=self.token.user, + client=self.token.client, + scope=self.token.scope) + # If the Token has an id_token it's an Authentication request. if self.token.id_token: id_token_dic = create_id_token( user=self.token.user, aud=self.client.client_id, nonce=None, + at_hash=token.at_hash, request=self.request, ) else: id_token_dic = {} - - token = create_token( - user=self.token.user, - client=self.token.client, - id_token_dic=id_token_dic, - scope=self.token.scope) + token.id_token = id_token_dic # Store the token. token.save() diff --git a/oidc_provider/lib/utils/token.py b/oidc_provider/lib/utils/token.py index fc0880d..680ee64 100644 --- a/oidc_provider/lib/utils/token.py +++ b/oidc_provider/lib/utils/token.py @@ -9,11 +9,15 @@ from jwkest.jwk import SYMKey from jwkest.jws import JWS from oidc_provider.lib.utils.common import get_issuer -from oidc_provider.models import * +from oidc_provider.models import ( + Code, + RSAKey, + Token, +) from oidc_provider import settings -def create_id_token(user, aud, nonce, request=None): +def create_id_token(user, aud, nonce, at_hash=None, request=None): """ Receives a user object and aud (audience). Then creates the id_token dictionary. @@ -44,6 +48,9 @@ def create_id_token(user, aud, nonce, request=None): if nonce: dic['nonce'] = str(nonce) + if at_hash: + dic['at_hash'] = at_hash + processing_hook = settings.get('OIDC_IDTOKEN_PROCESSING_HOOK') if isinstance(processing_hook, (list, tuple)): @@ -73,13 +80,13 @@ def encode_id_token(payload, client): keys = [SYMKey(key=client.client_secret, alg=alg)] else: raise Exception('Unsupported key algorithm.') - + _jws = JWS(payload, alg=alg) return _jws.sign_compact(keys) -def create_token(user, client, id_token_dic, scope): +def create_token(user, client, scope, id_token_dic=None): """ Create and populate a Token object. @@ -90,7 +97,8 @@ def create_token(user, client, id_token_dic, scope): token.client = client token.access_token = uuid.uuid4().hex - token.id_token = id_token_dic + if id_token_dic is not None: + token.id_token = id_token_dic token.refresh_token = uuid.uuid4().hex token.expires_at = timezone.now() + timedelta( @@ -112,7 +120,7 @@ def create_code(user, client, scope, nonce, is_authentication, code.client = client code.code = uuid.uuid4().hex - + if code_challenge and code_challenge_method: code.code_challenge = code_challenge code.code_challenge_method = code_challenge_method diff --git a/oidc_provider/migrations/0017_auto_20160811_1954.py b/oidc_provider/migrations/0017_auto_20160811_1954.py new file mode 100644 index 0000000..de7350f --- /dev/null +++ b/oidc_provider/migrations/0017_auto_20160811_1954.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-08-11 19:54 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oidc_provider', '0016_userconsent_and_verbosenames'), + ] + + 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 URIs'), + ), + migrations.AlterField( + model_name='client', + name='client_secret', + field=models.CharField(blank=True, default='', max_length=255, verbose_name='Client SECRET'), + ), + migrations.AlterField( + model_name='client', + name='client_type', + field=models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], default='confidential', help_text='Confidential clients are capable of maintaining the confidentiality of their credentials. Public clients are incapable.', max_length=30, verbose_name='Client Type'), + ), + migrations.AlterField( + model_name='client', + name='name', + field=models.CharField(default='', max_length=100, verbose_name='Name'), + ), + migrations.AlterField( + model_name='client', + name='response_type', + field=models.CharField(choices=[('code', 'code (Authorization Code Flow)'), ('id_token', 'id_token (Implicit Flow)'), ('id_token token', 'id_token token (Implicit Flow)')], max_length=30, verbose_name='Response Type'), + ), + migrations.AlterField( + model_name='code', + name='_scope', + field=models.TextField(default='', verbose_name='Scopes'), + ), + migrations.AlterField( + model_name='code', + name='nonce', + field=models.CharField(blank=True, default='', max_length=255, verbose_name='Nonce'), + ), + migrations.AlterField( + model_name='token', + name='_scope', + field=models.TextField(default='', verbose_name='Scopes'), + ), + migrations.AlterField( + model_name='userconsent', + name='_scope', + field=models.TextField(default='', verbose_name='Scopes'), + ), + ] diff --git a/oidc_provider/models.py b/oidc_provider/models.py index 09b36a2..0adda40 100644 --- a/oidc_provider/models.py +++ b/oidc_provider/models.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- -from hashlib import md5 +import base64 +import binascii +from hashlib import md5, sha256 import json from django.db import models @@ -24,6 +26,7 @@ JWT_ALGS = [ ('RS256', 'RS256'), ] + class Client(models.Model): name = models.CharField(max_length=100, default='', verbose_name=_(u'Name')) @@ -49,8 +52,10 @@ class Client(models.Model): def redirect_uris(): def fget(self): return self._redirect_uris.splitlines() + def fset(self, value): self._redirect_uris = '\n'.join(value) + return locals() redirect_uris = property(**redirect_uris()) @@ -69,8 +74,10 @@ class BaseCodeTokenModel(models.Model): def scope(): def fget(self): return self._scope.split() + def fset(self, value): self._scope = ' '.join(value) + return locals() scope = property(**scope()) @@ -105,11 +112,15 @@ class Token(BaseCodeTokenModel): 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) + def fset(self, value): self._id_token = json.dumps(value) + return locals() id_token = property(**id_token()) @@ -117,6 +128,18 @@ class Token(BaseCodeTokenModel): verbose_name = _(u'Token') verbose_name_plural = _(u'Tokens') + @property + def at_hash(self): + # @@@ d-o-p only supports 256 bits (change this if that changes) + hashed_access_token = sha256( + self.access_token.encode('ascii') + ).hexdigest().encode('ascii') + return base64.urlsafe_b64encode( + binascii.unhexlify( + hashed_access_token[:len(hashed_access_token) // 2] + ) + ).rstrip(b'=').decode('ascii') + class UserConsent(BaseCodeTokenModel): @@ -142,4 +165,4 @@ class RSAKey(models.Model): @property def kid(self): - return u'{0}'.format(md5(self.key.encode('utf-8')).hexdigest() if self.key else '') + return u'{0}'.format(md5(self.key.encode('utf-8')).hexdigest() if self.key else '') diff --git a/oidc_provider/tests/app/settings.py b/oidc_provider/tests/app/settings.py deleted file mode 100644 index 113f43c..0000000 --- a/oidc_provider/tests/app/settings.py +++ /dev/null @@ -1,61 +0,0 @@ -import os -from datetime import timedelta - - -DEBUG = False - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ':memory:', - } -} - -SITE_ID = 1 - -MIDDLEWARE_CLASSES = [ - 'django.middleware.common.CommonMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', -] - -LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'handlers': { - 'console': { - 'class': 'logging.StreamHandler', - }, - }, - 'loggers': { - 'oidc_provider': { - 'handlers': ['console'], - 'level': os.getenv('DJANGO_LOG_LEVEL', 'DEBUG'), - }, - }, -} - -INSTALLED_APPS = [ - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.sites', - 'django.contrib.messages', - 'django.contrib.admin', - 'oidc_provider', -] - -SECRET_KEY = 'this-should-be-top-secret' - -ROOT_URLCONF = 'oidc_provider.tests.app.urls' - -TEMPLATE_DIRS = [ - 'oidc_provider/tests/templates', -] - -USE_TZ = True - -# OIDC Provider settings. - -SITE_URL = 'http://localhost:8000' -OIDC_USERINFO = 'oidc_provider.tests.app.utils.userinfo' diff --git a/oidc_provider/tests/app/urls.py b/oidc_provider/tests/app/urls.py index abfb2c4..8c513fd 100644 --- a/oidc_provider/tests/app/urls.py +++ b/oidc_provider/tests/app/urls.py @@ -1,10 +1,10 @@ from django.contrib.auth import views as auth_views -from django.conf.urls import patterns, include, url +from django.conf.urls import include, url from django.contrib import admin from django.views.generic import TemplateView -urlpatterns = patterns('', +urlpatterns = [ url(r'^$', TemplateView.as_view(template_name='home.html'), name='home'), url(r'^accounts/login/$', auth_views.login, {'template_name': 'accounts/login.html'}, name='login'), url(r'^accounts/logout/$', auth_views.logout, {'template_name': 'accounts/logout.html'}, name='logout'), @@ -12,4 +12,4 @@ urlpatterns = patterns('', url(r'^openid/', include('oidc_provider.urls', namespace='oidc_provider')), url(r'^admin/', include(admin.site.urls)), -) +] diff --git a/oidc_provider/tests/app/utils.py b/oidc_provider/tests/app/utils.py index 616b130..4c58071 100644 --- a/oidc_provider/tests/app/utils.py +++ b/oidc_provider/tests/app/utils.py @@ -1,4 +1,3 @@ -import os import random import string try: @@ -8,7 +7,10 @@ except ImportError: from django.contrib.auth.models import User -from oidc_provider.models import * +from oidc_provider.models import ( + Client, + Code, +) FAKE_NONCE = 'cb584e44c43ed6bd0bc2d9c7e242837d' diff --git a/oidc_provider/tests/test_authorize_endpoint.py b/oidc_provider/tests/test_authorize_endpoint.py index dd22800..f323d36 100644 --- a/oidc_provider/tests/test_authorize_endpoint.py +++ b/oidc_provider/tests/test_authorize_endpoint.py @@ -1,25 +1,33 @@ try: - from urllib.parse import unquote, urlencode + from urllib.parse import urlencode except ImportError: - from urllib import unquote, urlencode + from urllib import urlencode +try: + from urllib.parse import parse_qs, urlsplit +except ImportError: + from urlparse import parse_qs, urlsplit 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 +from jwkest.jwt import JWT from oidc_provider import settings -from oidc_provider.models import * -from oidc_provider.tests.app.utils import * -from oidc_provider.views import * +from oidc_provider.tests.app.utils import ( + create_fake_user, + create_fake_client, + FAKE_CODE_CHALLENGE, + is_code_valid, +) +from oidc_provider.views import AuthorizeView class AuthorizationCodeFlowTestCase(TestCase): """ - Test cases for Authorize Endpoint using Authorization Code Flow. + Test cases for Authorize Endpoint using Code Flow. """ def setUp(self): @@ -28,7 +36,6 @@ class AuthorizationCodeFlowTestCase(TestCase): 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 @@ -52,7 +59,6 @@ class AuthorizationCodeFlowTestCase(TestCase): return response - def test_missing_parameters(self): """ If the request fails due to a missing, invalid, or mismatching @@ -148,7 +154,7 @@ class AuthorizationCodeFlowTestCase(TestCase): for key, value in iter(to_check.items()): is_input_ok = input_html.format(key, value) in response.content.decode('utf-8') self.assertEqual(is_input_ok, True, - msg='Hidden input for "'+key+'" fails.') + msg='Hidden input for "' + key + '" fails.') def test_user_consent_response(self): """ @@ -183,7 +189,7 @@ class AuthorizationCodeFlowTestCase(TestCase): msg='"access_denied" code is missing in query.') # Simulate user authorization. - data['allow'] = 'Accept' # Will be the value of the button. + data['allow'] = 'Accept' # Will be the value of the button. response = self._auth_request('post', data, is_user_authenticated=True) @@ -270,43 +276,6 @@ class AuthorizationCodeFlowTestCase(TestCase): self.assertEqual('Request for Permission' in response.content.decode('utf-8'), True) - def test_implicit_missing_nonce(self): - """ - The `nonce` parameter is REQUIRED if you use the Implicit Flow. - """ - data = { - '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, - } - - response = self._auth_request('get', data, is_user_authenticated=True) - - self.assertEqual('#error=invalid_request' in response['Location'], True) - - 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. - """ - 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', - } - - response = self._auth_request('post', data, is_user_authenticated=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. @@ -331,3 +300,168 @@ class AuthorizationCodeFlowTestCase(TestCase): # 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) + + +class AuthorizationImplicitFlowTestCase(TestCase): + """ + Test cases for Authorization Endpoint using Implicit Flow. + """ + + def setUp(self): + call_command('creatersakey') + self.factory = RequestFactory() + self.user = create_fake_user() + self.client = create_fake_client(response_type='id_token token') + self.client_public = create_fake_client(response_type='id_token token', is_public=True) + self.client_no_access = create_fake_client(response_type='id_token') + self.client_public_no_access = create_fake_client(response_type='id_token', is_public=True) + self.state = uuid.uuid4().hex + self.nonce = uuid.uuid4().hex + + def _auth_request(self, method, data={}, is_user_authenticated=False): + url = reverse('oidc_provider:authorize') + + if method.lower() == 'get': + 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=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_nonce(self): + """ + The `nonce` parameter is REQUIRED if you use the Implicit Flow. + """ + 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, + } + + response = self._auth_request('get', data, is_user_authenticated=True) + + self.assertEqual('#error=invalid_request' in response['Location'], True) + + def test_id_token_token_response(self): + """ + Implicit client requesting `id_token token` receives both id token + and access token as the result of the authorization request. + """ + data = { + 'client_id': self.client.client_id, + 'redirect_uri': self.client.default_redirect_uri, + 'response_type': self.client.response_type, + 'scope': 'openid email', + 'state': self.state, + 'nonce': self.nonce, + 'allow': 'Accept', + } + + response = self._auth_request('post', data, is_user_authenticated=True) + + self.assertIn('access_token', response['Location']) + self.assertIn('id_token', response['Location']) + + # same for public client + data['client_id'] = self.client_public.client_id, + data['redirect_uri'] = self.client_public.default_redirect_uri, + data['response_type'] = self.client_public.response_type, + + response = self._auth_request('post', data, is_user_authenticated=True) + + self.assertIn('access_token', response['Location']) + self.assertIn('id_token', response['Location']) + + def test_id_token_response(self): + """ + Implicit client requesting `id_token` receives + only an id token as the result of the authorization request. + """ + data = { + 'client_id': self.client_no_access.client_id, + 'redirect_uri': self.client_no_access.default_redirect_uri, + 'response_type': self.client_no_access.response_type, + 'scope': 'openid email', + 'state': self.state, + 'nonce': self.nonce, + 'allow': 'Accept', + } + + response = self._auth_request('post', data, is_user_authenticated=True) + + self.assertNotIn('access_token', response['Location']) + self.assertIn('id_token', response['Location']) + + # same for public client + data['client_id'] = self.client_public_no_access.client_id, + data['redirect_uri'] = self.client_public_no_access.default_redirect_uri, + data['response_type'] = self.client_public_no_access.response_type, + + response = self._auth_request('post', data, is_user_authenticated=True) + + self.assertNotIn('access_token', response['Location']) + self.assertIn('id_token', response['Location']) + + def test_id_token_token_at_hash(self): + """ + Implicit client requesting `id_token token` receives + `at_hash` in `id_token`. + """ + data = { + 'client_id': self.client.client_id, + 'redirect_uri': self.client.default_redirect_uri, + 'response_type': self.client.response_type, + 'scope': 'openid email', + 'state': self.state, + 'nonce': self.nonce, + 'allow': 'Accept', + } + + response = self._auth_request('post', data, is_user_authenticated=True) + + self.assertIn('id_token', response['Location']) + + # obtain `id_token` portion of Location + components = urlsplit(response['Location']) + fragment = parse_qs(components[4]) + id_token = JWT().unpack(fragment["id_token"][0].encode('utf-8')).payload() + + self.assertIn('at_hash', id_token) + + def test_id_token_at_hash(self): + """ + Implicit client requesting `id_token` should not receive + `at_hash` in `id_token`. + """ + data = { + 'client_id': self.client_no_access.client_id, + 'redirect_uri': self.client_no_access.default_redirect_uri, + 'response_type': self.client_no_access.response_type, + 'scope': 'openid email', + 'state': self.state, + 'nonce': self.nonce, + 'allow': 'Accept', + } + + response = self._auth_request('post', data, is_user_authenticated=True) + + self.assertIn('id_token', response['Location']) + + # obtain `id_token` portion of Location + components = urlsplit(response['Location']) + fragment = parse_qs(components[4]) + id_token = JWT().unpack(fragment["id_token"][0].encode('utf-8')).payload() + + self.assertNotIn('at_hash', id_token) diff --git a/oidc_provider/tests/test_claims.py b/oidc_provider/tests/test_claims.py new file mode 100644 index 0000000..92429b3 --- /dev/null +++ b/oidc_provider/tests/test_claims.py @@ -0,0 +1,46 @@ +from django.test import TestCase +from oidc_provider.lib.claims import ScopeClaims +from oidc_provider.tests.app.utils import create_fake_user + + +class ClaimsTestCase(TestCase): + + def setUp(self): + self.user = create_fake_user() + self.scopes = ['openid', 'address', 'email', 'phone', 'profile'] + self.scopeClaims = ScopeClaims(self.user, self.scopes) + + def test_clean_dic(self): + """ assert that _clean_dic function returns a clean dictionnary + (no empty claims) """ + dict_to_clean = { + 'phone_number_verified': '', + 'middle_name': '', + 'name': 'John Doe', + 'website': '', + 'profile': '', + 'family_name': 'Doe', + 'birthdate': '', + 'preferred_username': '', + 'picture': '', + 'zoneinfo': '', + 'locale': '', + 'gender': '', + 'updated_at': '', + 'address': {}, + 'given_name': 'John', + 'email_verified': '', + 'nickname': '', + 'email': u'johndoe@example.com', + 'phone_number': '', + } + clean_dict = self.scopeClaims._clean_dic(dict_to_clean) + self.assertEquals( + clean_dict, + { + 'family_name': 'Doe', + 'given_name': 'John', + 'name': 'John Doe', + 'email': u'johndoe@example.com' + } + ) diff --git a/oidc_provider/tests/test_commands.py b/oidc_provider/tests/test_commands.py new file mode 100644 index 0000000..cb070ec --- /dev/null +++ b/oidc_provider/tests/test_commands.py @@ -0,0 +1,16 @@ +from django.core.management import call_command +from django.test import TestCase +from django.utils.six import StringIO + + +class CommandsTest(TestCase): + + def test_creatersakey_output(self): + out = StringIO() + call_command('creatersakey', stdout=out) + self.assertIn('RSA key successfully created', out.getvalue()) + + def test_makemigrations_output(self): + out = StringIO() + call_command('makemigrations', 'oidc_provider', stdout=out) + self.assertIn('No changes detected in app', out.getvalue()) diff --git a/oidc_provider/tests/test_creatersakey_command.py b/oidc_provider/tests/test_creatersakey_command.py deleted file mode 100644 index d9424f6..0000000 --- a/oidc_provider/tests/test_creatersakey_command.py +++ /dev/null @@ -1,12 +0,0 @@ -from django.core.management import call_command -from django.test import TestCase, override_settings -from django.utils.six import StringIO - - -class CreateRSAKeyTest(TestCase): - - @override_settings(BASE_DIR='/tmp') - def test_command_output(self): - out = StringIO() - call_command('creatersakey', stdout=out) - self.assertIn('RSA key successfully created', out.getvalue()) diff --git a/oidc_provider/tests/test_logout_endpoint.py b/oidc_provider/tests/test_logout_endpoint.py index 40f4200..b9d1684 100644 --- a/oidc_provider/tests/test_logout_endpoint.py +++ b/oidc_provider/tests/test_logout_endpoint.py @@ -1,8 +1,7 @@ from django.core.urlresolvers import reverse from django.test import TestCase -from oidc_provider.views import * -from oidc_provider.tests.app.utils import * +from oidc_provider.tests.app.utils import create_fake_user class UserInfoTestCase(TestCase): diff --git a/oidc_provider/tests/test_provider_info_endpoint.py b/oidc_provider/tests/test_provider_info_endpoint.py index 1b205bc..0c01bc5 100644 --- a/oidc_provider/tests/test_provider_info_endpoint.py +++ b/oidc_provider/tests/test_provider_info_endpoint.py @@ -2,7 +2,7 @@ from django.core.urlresolvers import reverse from django.test import RequestFactory from django.test import TestCase -from oidc_provider.views import * +from oidc_provider.views import ProviderInfoView class ProviderInfoTestCase(TestCase): @@ -23,4 +23,4 @@ class ProviderInfoTestCase(TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response['Content-Type'] == 'application/json', True) - self.assertEqual(bool(response.content), True) \ No newline at end of file + self.assertEqual(bool(response.content), True) diff --git a/oidc_provider/tests/test_token_endpoint.py b/oidc_provider/tests/test_token_endpoint.py index 0873b23..7c43122 100644 --- a/oidc_provider/tests/test_token_endpoint.py +++ b/oidc_provider/tests/test_token_endpoint.py @@ -1,3 +1,7 @@ +from datetime import timedelta +import json +import uuid + from base64 import b64encode try: from urllib.parse import urlencode @@ -5,15 +9,30 @@ except ImportError: from urllib import urlencode from django.core.management import call_command +from django.core.urlresolvers import reverse from django.test import RequestFactory, override_settings from django.test import TestCase +from django.utils import timezone from jwkest.jwk import KEYS +from jwkest.jws import JWS from jwkest.jwt import JWT from mock import patch -from oidc_provider.lib.utils.token import * -from oidc_provider.tests.app.utils import * -from oidc_provider.views import * +from oidc_provider.lib.utils.token import create_code +from oidc_provider.models import Token +from oidc_provider.tests.app.utils import ( + create_fake_user, + create_fake_client, + FAKE_CODE_CHALLENGE, + FAKE_CODE_VERIFIER, + FAKE_NONCE, + FAKE_RANDOM_STRING, +) +from oidc_provider.views import ( + JwksView, + TokenView, + userinfo, +) class TokenTestCase(TestCase): @@ -208,14 +227,14 @@ class TokenTestCase(TestCase): response = TokenView.as_view()(request) self.assertEqual(response.status_code == 405, True, - msg=request.method+' request does not return a 405 status.') + msg=request.method + ' request does not return a 405 status.') request = self.factory.post(url) response = TokenView.as_view()(request) self.assertEqual(response.status_code == 400, True, - msg=request.method+' request does not return a 400 status.') + msg=request.method + ' request does not return a 400 status.') def test_client_authentication(self): """ @@ -238,7 +257,7 @@ class TokenTestCase(TestCase): # Now, test with an invalid client_id. invalid_data = post_data.copy() - invalid_data['client_id'] = self.client.client_id * 2 # Fake id. + invalid_data['client_id'] = self.client.client_id * 2 # Fake id. # Create another grant code. code = self._create_code() @@ -264,8 +283,8 @@ class TokenTestCase(TestCase): user_pass = self.client.client_id + ':' + self.client.client_secret auth_header = b'Basic ' + b64encode(user_pass.encode('utf-8')) response = self._post_request(basicauth_data, { - 'HTTP_AUTHORIZATION': auth_header.decode('utf-8'), - }) + 'HTTP_AUTHORIZATION': auth_header.decode('utf-8'), + }) response.content.decode('utf-8') self.assertEqual('invalid_client' in response.content.decode('utf-8'), @@ -304,6 +323,21 @@ class TokenTestCase(TestCase): self.assertEqual(id_token.get('nonce'), None) + def test_id_token_contains_at_hash(self): + """ + If access_token is included, the id_token SHOULD contain an at_hash. + """ + code = self._create_code() + + post_data = self._auth_code_post_data(code=code.code) + + response = self._post_request(post_data) + + response_dic = json.loads(response.content.decode('utf-8')) + id_token = JWT().unpack(response_dic['id_token'].encode('utf-8')).payload() + + self.assertTrue(id_token.get('at_hash')) + def test_idtoken_sign_validation(self): """ We MUST validate the signature of the ID Token according to JWS @@ -311,7 +345,7 @@ class TokenTestCase(TestCase): the JOSE Header. """ SIGKEYS = self._get_keys() - RSAKEYS = [ k for k in SIGKEYS if k.kty == 'RSA' ] + RSAKEYS = [k for k in SIGKEYS if k.kty == 'RSA'] code = self._create_code() diff --git a/oidc_provider/tests/test_userinfo_endpoint.py b/oidc_provider/tests/test_userinfo_endpoint.py index a68e011..78dc8d4 100644 --- a/oidc_provider/tests/test_userinfo_endpoint.py +++ b/oidc_provider/tests/test_userinfo_endpoint.py @@ -1,3 +1,5 @@ +import json + from datetime import timedelta try: from urllib.parse import urlencode @@ -9,9 +11,15 @@ from django.test import RequestFactory from django.test import TestCase from django.utils import timezone -from oidc_provider.lib.utils.token import * -from oidc_provider.models import * -from oidc_provider.tests.app.utils import * +from oidc_provider.lib.utils.token import ( + create_id_token, + create_token, +) +from oidc_provider.tests.app.utils import ( + create_fake_user, + create_fake_client, + FAKE_NONCE, +) from oidc_provider.views import userinfo diff --git a/oidc_provider/urls.py b/oidc_provider/urls.py index 531e3f6..bd9e911 100644 --- a/oidc_provider/urls.py +++ b/oidc_provider/urls.py @@ -1,16 +1,14 @@ -from django.conf.urls import patterns, include, url +from django.conf.urls import url from django.views.decorators.csrf import csrf_exempt -from oidc_provider.views import * +from oidc_provider import views urlpatterns = [ + url(r'^authorize/?$', views.AuthorizeView.as_view(), name='authorize'), + url(r'^token/?$', csrf_exempt(views.TokenView.as_view()), name='token'), + url(r'^userinfo/?$', csrf_exempt(views.userinfo), name='userinfo'), + url(r'^logout/?$', views.LogoutView.as_view(), name='logout'), - url(r'^authorize/?$', AuthorizeView.as_view(), name='authorize'), - url(r'^token/?$', csrf_exempt(TokenView.as_view()), name='token'), - url(r'^userinfo/?$', csrf_exempt(userinfo), name='userinfo'), - url(r'^logout/?$', LogoutView.as_view(), name='logout'), - - url(r'^\.well-known/openid-configuration/?$', ProviderInfoView.as_view(), name='provider_info'), - url(r'^jwks/?$', JwksView.as_view(), name='jwks'), - + url(r'^\.well-known/openid-configuration/?$', views.ProviderInfoView.as_view(), name='provider_info'), + url(r'^jwks/?$', views.JwksView.as_view(), name='jwks'), ] diff --git a/oidc_provider/views.py b/oidc_provider/views.py index a22ca17..e2a2b31 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -1,3 +1,5 @@ +import logging + from Crypto.PublicKey import RSA from django.contrib.auth.views import redirect_to_login, logout from django.core.urlresolvers import reverse @@ -9,9 +11,14 @@ from django.views.generic import View from jwkest import long_to_base64 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.endpoints.authorize import AuthorizeEndpoint +from oidc_provider.lib.endpoints.token import TokenEndpoint +from oidc_provider.lib.errors import ( + AuthorizeError, + ClientIdError, + RedirectUriError, + TokenError, +) 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 @@ -193,8 +200,8 @@ class ProviderInfoView(View): # See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes dic['subject_types_supported'] = ['public'] - dic['token_endpoint_auth_methods_supported'] = [ 'client_secret_post', - 'client_secret_basic' ] + dic['token_endpoint_auth_methods_supported'] = ['client_secret_post', + 'client_secret_basic'] return JsonResponse(dic) @@ -205,7 +212,7 @@ class JwksView(View): dic = dict(keys=[]) for rsakey in RSAKey.objects.all(): - public_key = RSA.importKey(rsakey.key).publickey() + public_key = RSA.importKey(rsakey.key).publickey() dic['keys'].append({ 'kty': 'RSA', 'alg': 'RS256', diff --git a/runtests.py b/runtests.py new file mode 100644 index 0000000..1557853 --- /dev/null +++ b/runtests.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python +import os +import sys + +import django + +from django.conf import settings + + +DEFAULT_SETTINGS = dict( + + DEBUG = False, + + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', + } + }, + + SITE_ID = 1, + + MIDDLEWARE_CLASSES = [ + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + ], + + TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, + ], + + LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + }, + }, + 'loggers': { + 'oidc_provider': { + 'handlers': ['console'], + 'level': os.getenv('DJANGO_LOG_LEVEL', 'DEBUG'), + }, + }, + }, + + INSTALLED_APPS = [ + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.sites', + 'django.contrib.messages', + 'django.contrib.admin', + 'oidc_provider', + ], + + SECRET_KEY = 'this-should-be-top-secret', + + ROOT_URLCONF = 'oidc_provider.tests.app.urls', + + TEMPLATE_DIRS = [ + 'oidc_provider/tests/templates', + ], + + USE_TZ = True, + + # OIDC Provider settings. + + SITE_URL = 'http://localhost:8000', + OIDC_USERINFO = 'oidc_provider.tests.app.utils.userinfo', + +) + + +def runtests(*test_args): + if not settings.configured: + settings.configure(**DEFAULT_SETTINGS) + + django.setup() + + parent = os.path.dirname(os.path.abspath(__file__)) + sys.path.insert(0, parent) + + try: + from django.test.runner import DiscoverRunner + runner_class = DiscoverRunner + if not test_args: + test_args = ["oidc_provider.tests"] + except ImportError: + from django.test.simple import DjangoTestSuiteRunner + runner_class = DjangoTestSuiteRunner + if not test_args: + test_args = ["tests"] + + failures = runner_class(verbosity=1, interactive=True, failfast=False).run_tests(test_args) + sys.exit(failures) + + +if __name__ == "__main__": + runtests(*sys.argv[1:]) diff --git a/setup.py b/setup.py index 6fd3783..7183e06 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.6', + version='0.3.7', packages=[ 'oidc_provider', 'oidc_provider/lib', 'oidc_provider/lib/endpoints', 'oidc_provider/lib/utils', 'oidc_provider/tests', 'oidc_provider/tests/app', @@ -34,9 +34,10 @@ setup( 'Topic :: Internet :: WWW/HTTP', 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', ], + test_suite='runtests.runtests', tests_require=[ 'pyjwkest==1.1.0', - 'mock==1.3.0', + 'mock==2.0.0', ], install_requires=[ diff --git a/tox.ini b/tox.ini index 48d8589..849f411 100644 --- a/tox.ini +++ b/tox.ini @@ -1,21 +1,23 @@ [tox] envlist= - clean,py{27,34}-django{17,18,19},py35-django{18,19},stats + clean, + py27-django{17,18,19,110}, + py34-django{17,18,19,110}, + py35-django{18,19,110}, [testenv] deps = django17: django>=1.7,<1.8 django18: django>=1.8,<1.9 - django19: django>=1.9,<2.0 + django19: django>=1.9,<1.10 + django110: django>=1.10,<1.11 coverage mock commands = - pip uninstall --yes django-oidc-provider - pip install -e . - coverage run --omit=.tox/*,oidc_provider/tests/* {envbindir}/django-admin.py test {posargs:oidc_provider} --settings=oidc_provider.tests.app.settings + coverage run setup.py test [testenv:clean]