Merge pull request #6 from juanifioren/v0.3.x

Fetch lates version
This commit is contained in:
Wojciech Bartosiak 2016-09-06 12:20:57 +02:00 committed by GitHub
commit 6f2204f78f
30 changed files with 653 additions and 227 deletions

2
.gitignore vendored
View file

@ -10,3 +10,5 @@ src/
.venv
.idea
docs/_build/
.eggs/
.python-version

View file

@ -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

View file

@ -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

View file

@ -1,4 +1,4 @@
include LICENSE
include README.rst
include README.md
recursive-include oidc_provider/templates *
recursive-include oidc_provider/tests/templates *

View file

@ -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.

View file

@ -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

View file

@ -18,12 +18,11 @@ Use `tox <https://pypi.python.org/pypi/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 <https://travis-ci.org/juanifioren/django-oidc-provider/>`_ for this.

View file

@ -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
==================

View file

@ -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

View file

@ -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:

View file

@ -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()

View file

@ -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

View file

@ -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='<b>Confidential</b> clients are capable of maintaining the confidentiality of their credentials. <b>Public</b> 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'),
),
]

View file

@ -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 '')

View file

@ -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'

View file

@ -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)),
)
]

View file

@ -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'

View file

@ -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)

View file

@ -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'
}
)

View file

@ -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())

View file

@ -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())

View file

@ -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):

View file

@ -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)
self.assertEqual(bool(response.content), True)

View file

@ -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()

View file

@ -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

View file

@ -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'),
]

View file

@ -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',

114
runtests.py Normal file
View file

@ -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:])

View file

@ -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=[

12
tox.ini
View file

@ -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]