Merge pull request #8 from juanifioren/v0.4.x

keeping it updated
This commit is contained in:
Wojciech Bartosiak 2016-10-04 19:15:38 +02:00 committed by GitHub
commit 60b2cf27af
25 changed files with 450 additions and 260 deletions

View file

@ -4,13 +4,24 @@ All notable changes to this project will be documented in this file.
### [Unreleased]
### [0.4.1] - 2016-10-03
##### Changed
- Update pyjwkest to version 1.3.0.
- Use Cryptodome instead of Crypto lib.
### [0.4.0] - 2016-09-12
##### Added
- Support for Hybrid Flow.
- New attributes for Clients: Website url, logo, contact email, terms url.
- Polish translations.
- Examples section in documentation.
##### Fixed
- CORS in discovery and userinfo endpoint.
- Client type public bug when created using the admin.
- Missing OIDC_TOKEN_EXPIRE setting on implicit flow.
### [0.3.7] - 2016-08-31

View file

@ -2,8 +2,8 @@
[![Python Versions](https://img.shields.io/pypi/pyversions/django-oidc-provider.svg)](https://pypi.python.org/pypi/django-oidc-provider)
[![PyPI Versions](https://img.shields.io/pypi/v/django-oidc-provider.svg)](https://pypi.python.org/pypi/django-oidc-provider)
[![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)
[![Documentation Status](https://readthedocs.org/projects/django-oidc-provider/badge/?version=v0.4.x)](http://django-oidc-provider.readthedocs.io/en/v0.4.x/?badge=v0.4.x)
[![Travis](https://travis-ci.org/juanifioren/django-oidc-provider.svg?branch=v0.4.x)](https://travis-ci.org/juanifioren/django-oidc-provider)
## About OpenID
@ -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/master/CHANGELOG.md).
[Read docs for more info](http://django-oidc-provider.readthedocs.org/).
## Contributing

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 116 KiB

View file

@ -1,7 +1,7 @@
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.
This tiny (but powerful!) package can help you providing out of the box all the endpoints, data and logic needed to add OpenID Connect capabilities to your Django projects. And as a side effect a fair implementation of OAuth2.0 too. Covers Authorization Code, Implicit and Hybrid flows.
Also implements the following specifications:
@ -13,7 +13,6 @@ 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 library covers **Authorization Code Flow** and **Implicit Flow**, NO support for **Hybrid Flow** at this moment.
* Supports only for requesting Claims using Scope values.
--------------------------------------------------------------------------------
@ -27,9 +26,10 @@ Contents:
sections/relyingparties
sections/serverkeys
sections/templates
sections/claims
sections/scopesclaims
sections/userconsent
sections/oauth2
sections/accesstokens
sections/settings
sections/examples
sections/contribute

View file

@ -0,0 +1,64 @@
.. _accesstokens:
Access Tokens
#############
At the end of the login process, an access token is generated. This access token is the thing that's passed along with every API call (e.g. userinfo endpoint) as proof that the call was made by a specific person from a specific app.
Access tokens generally have a lifetime of only a couple of hours, you can use ``OIDC_TOKEN_EXPIRE`` to set custom expiration that suit your needs.
Obtaining an Access token
=========================
Go to the admin site and create a confidential client with ``response_type = code`` and ``redirect_uri = http://example.org/``.
Open your browser and accept consent at::
http://localhost:8000/authorize?client_id=651462&redirect_uri=http://example.org/&response_type=code&scope=openid email profile&state=123123
In the redirected URL you should have a ``code`` parameter included as query string::
http://example.org/?code=b9cedb346ee04f15ab1d3ac13da92002&state=123123
We use ``code`` value to obtain ``access_token`` and ``refresh_token``::
curl -X POST \
-H "Cache-Control: no-cache" \
-H "Content-Type: application/x-www-form-urlencoded" \
"http://localhost:8000/token/" \
-d "client_id=651462" \
-d "client_secret=37b1c4ff826f8d78bd45e25bad75a2c0" \
-d "code=b9cedb346ee04f15ab1d3ac13da92002" \
-d "redirect_uri=http://example.org/" \
-d "grant_type=authorization_code"
Example response::
{
"access_token": "82b35f3d810f4cf49dd7a52d4b22a594",
"token_type": "bearer",
"expires_in": 3600,
"refresh_token": "0bac2d80d75d46658b0b31d3778039bb",
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6..."
}
Then you can grab the access token and ask user data by doing a GET request to the ``/userinfo`` endpoint::
curl -X GET \
-H "Cache-Control: no-cache" \
"http://localhost:8000/userinfo/?access_token=82b35f3d810f4cf49dd7a52d4b22a594"
Expiration and Refresh of Access Tokens
=======================================
If you receive a ``401 Unauthorized`` status when issuing access token probably means that has expired.
The RP application obtains a new access token by sending a POST request to the ``/token`` endpoint with the following request parameters::
curl -X POST \
-H "Cache-Control: no-cache" \
-H "Content-Type: application/x-www-form-urlencoded" \
"http://localhost:8000/token/" \
-d "client_id=651462" \
-d "grant_type=refresh_token" \
-d "refresh_token=0bac2d80d75d46658b0b31d3778039bb"

View file

@ -18,14 +18,22 @@ Properties
* ``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``.
* ``response_type``: Values depends of wich flow you want use.
* ``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.
* ``redirect_uris``: List of redirect URIs.
Optional information:
* ``website_url``: Website URL of your client.
* ``terms_url``: External reference to the privacy policy of the client.
* ``contact_email``: Contact email.
* ``logo``: Logo image.
Using the admin
===============
We suggest you to use Django admin to easily manage your clients:
We suggest you to use Django admin to easily manage your clients:
.. image:: ../images/client_creation.png
:align: center

View file

@ -1,13 +1,13 @@
.. _claims:
.. _scopesclaims:
Standard Claims
###############
Scopes and Claims
#################
This subset of OpenID Connect defines a set of standard Claims. They are returned in the UserInfo Response.
The package comes with a setting called ``OIDC_USERINFO``, basically it refers to a class that MUST have a class-method named ``get_by_user``, this will be called with a Django ``User`` instance and returns an object with all the claims of the user as attributes.
The package comes with a setting called ``OIDC_USERINFO``, basically it refers to a function that will be called with ``claims`` (dict) and ``user`` (user instance). It returns the ``claims`` dict with all the claims populated.
List of all the attributes grouped by scopes:
List of all the ``claims`` keys grouped by scopes:
+--------------------+----------------+-----------------------+------------------------+
| profile | email | phone | address |
@ -41,15 +41,18 @@ List of all the attributes grouped by scopes:
| updated_at | | | |
+--------------------+----------------+-----------------------+------------------------+
How to populate standard claims
===============================
Somewhere in your Django ``settings.py``::
OIDC_USERINFO = 'myproject.oidc_provider_settings.userinfo'
Then create the function for the ``OIDC_USERINFO`` setting::
Then inside your ``oidc_provider_settings.py`` file create the function for the ``OIDC_USERINFO`` setting::
def userinfo(claims, user):
# Populate claims dict.
claims['name'] = '{0} {1}'.format(user.first_name, user.last_name)
claims['given_name'] = user.first_name
claims['family_name'] = user.last_name
@ -58,5 +61,52 @@ Then create the function for the ``OIDC_USERINFO`` setting::
return claims
Now test an Authorization Request using these scopes ``openid profile email`` and see how user attributes are returned.
.. 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.
How to add custom scopes and claims
===================================
The ``OIDC_EXTRA_SCOPE_CLAIMS`` setting is used to add extra scopes specific for your app. Is just a class that inherit from ``oidc_provider.lib.claims.ScopeClaims``. You can create or modify scopes by adding this methods into it:
* ``info_scopename`` class property for setting the verbose name and description.
* ``scope_scopename`` method for returning some information related.
Let's say that you want add your custom ``foo`` scope for your OAuth2/OpenID provider. So when a client (RP) makes an Authorization Request containing ``foo`` in the list of scopes, it will be listed in the consent page (``templates/oidc_provider/authorize.html``) and then some specific claims like ``bar`` will be returned from the ``/userinfo`` response.
Somewhere in your Django ``settings.py``::
OIDC_USERINFO = 'yourproject.oidc_provider_settings.CustomScopeClaims'
Inside your oidc_provider_settings.py file add the following class::
from django.utils.translation import ugettext as _
from oidc_provider.lib.claims import ScopeClaims
class CustomScopeClaims(ScopeClaims):
info_foo = (
_(u'Foo'),
_(u'Some description for the scope.'),
)
def scope_foo(self):
# self.user - Django user instance.
# self.userinfo - Dict returned by OIDC_USERINFO function.
# self.scopes - List of scopes requested.
dic = {
'bar': 'Something dynamic here',
}
return dic
# If you want to change the description of the profile scope, you can redefine it.
info_profile = (
_(u'Profile'),
_(u'Another description.'),
)
.. note::
If a field is empty or ``None`` inside the dictionary you return on the ``scope_scopename`` method, it will be cleaned from the response.

View file

@ -48,54 +48,15 @@ OIDC_EXTRA_SCOPE_CLAIMS
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 ``ScopeClaims``.
Used to add extra scopes specific for your app. OpenID Connect RP's will use scope values to specify what access privileges are being requested for Access Tokens.
OpenID Connect Clients will use scope values to specify what access privileges are being requested for Access Tokens.
Read more about how to implement it in :ref:`scopesclaims` section.
`Here <http://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims>`_ 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.
# self.userinfo - Instance of your custom OIDC_USERINFO class.
# self.scopes - List of scopes requested.
dic = {
'books_readed': books_readed_count,
}
return dic
# If you want to change the description of the profile scope, you can redefine it.
info_profile = (
_(u'Profile'),
_(u'Another description.'),
)
.. 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
===================
OPTIONAL. ``int``. Token object expiration after been delivered.
OPTIONAL. ``int``. ID Token expiration after been delivered.
Expressed in seconds. Default is ``60*10``.
@ -155,7 +116,7 @@ Expressed in days. Default is ``30*3``.
OIDC_TOKEN_EXPIRE
=================
OPTIONAL. ``int``. Token object expiration after been created.
OPTIONAL. ``int``. Token object (access token) expiration after been created.
Expressed in seconds. Default is ``60*60``.

View file

@ -1,5 +1,5 @@
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

View file

@ -1,2 +1,2 @@
django==1.9
https://github.com/juanifioren/django-oidc-provider/archive/v0.3.x.zip
django==1.10
https://github.com/juanifioren/django-oidc-provider/archive/v0.4.x.zip

View file

@ -4,6 +4,7 @@ from uuid import uuid4
from django.forms import ModelForm
from django.contrib import admin
from django.utils.translation import ugettext_lazy as _
from oidc_provider.models import Client, Code, Token, RSAKey
@ -48,6 +49,17 @@ class ClientForm(ModelForm):
@admin.register(Client)
class ClientAdmin(admin.ModelAdmin):
fieldsets = [
[_(u''), {
'fields': ('name', 'client_type', 'response_type','_redirect_uris', 'jwt_alg'),
}],
[_(u'Credentials'), {
'fields': ('client_id', 'client_secret'),
}],
[_(u'Information'), {
'fields': ('contact_email', 'website_url', 'terms_url', 'logo', 'date_created'),
}],
]
form = ClientForm
list_display = ['name', 'client_id', 'response_type', 'date_created']
readonly_fields = ['date_created']

View file

@ -14,7 +14,6 @@ from oidc_provider.lib.errors import (
ClientIdError,
RedirectUriError,
)
from oidc_provider.lib.utils.params import Params
from oidc_provider.lib.utils.token import (
create_code,
create_id_token,
@ -35,25 +34,27 @@ class AuthorizeEndpoint(object):
def __init__(self, request):
self.request = request
self.params = Params()
self.params = {}
self._extract_params()
# Determine which flow to use.
if self.params.response_type in ['code']:
if self.params['response_type'] in ['code']:
self.grant_type = 'authorization_code'
elif self.params.response_type in ['id_token', 'id_token token', 'token']:
elif self.params['response_type'] in ['id_token', 'id_token token', 'token']:
self.grant_type = 'implicit'
elif self.params['response_type'] in ['code token', 'code id_token', 'code id_token token']:
self.grant_type = 'hybrid'
else:
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):
"""
Get all the params used by the Authorization Code Flow
(and also for the Implicit).
(and also for the Implicit and Hybrid).
See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
"""
@ -62,102 +63,99 @@ class AuthorizeEndpoint(object):
query_dict = (self.request.POST if self.request.method == 'POST'
else self.request.GET)
self.params.client_id = query_dict.get('client_id', '')
self.params.redirect_uri = query_dict.get('redirect_uri', '')
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', '')
self.params.code_challenge_method = query_dict.get('code_challenge_method', '')
self.params['client_id'] = query_dict.get('client_id', '')
self.params['redirect_uri'] = query_dict.get('redirect_uri', '')
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', '')
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)
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)
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:
if self.is_authentication and not self.params['redirect_uri']:
logger.debug('[Authorize] Missing redirect uri.')
raise RedirectUriError()
clean_redirect_uri = urlsplit(self.params.redirect_uri)
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)
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)
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)
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)
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']):
raise AuthorizeError(self.params.redirect_uri, 'invalid_request', self.grant_type)
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)
def create_response_uri(self):
uri = urlsplit(self.params.redirect_uri)
uri = urlsplit(self.params['redirect_uri'])
query_params = parse_qs(uri.query)
query_fragment = parse_qs(uri.fragment)
try:
if self.grant_type == 'authorization_code':
if self.grant_type in ['authorization_code', 'hybrid']:
code = create_code(
user=self.request.user,
client=self.client,
scope=self.params.scope,
nonce=self.params.nonce,
scope=self.params['scope'],
nonce=self.params['nonce'],
is_authentication=self.is_authentication,
code_challenge=self.params.code_challenge,
code_challenge_method=self.params.code_challenge_method)
code_challenge=self.params['code_challenge'],
code_challenge_method=self.params['code_challenge_method'])
code.save()
if self.grant_type == 'authorization_code':
query_params['code'] = code.code
query_params['state'] = self.params.state if self.params.state else ''
elif self.grant_type == 'implicit':
query_params['state'] = self.params['state'] if self.params['state'] else ''
elif self.grant_type in ['implicit', 'hybrid']:
token = create_token(
user=self.request.user,
client=self.client,
scope=self.params.scope)
scope=self.params['scope'])
# 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']:
# Check if response_type must include access_token in the response.
if self.params['response_type'] in ['id_token token', 'token', 'code token', 'code id_token token']:
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
'user': self.request.user,
'aud': self.client.client_id,
'nonce': self.params['nonce'],
'request': self.request,
'scope': self.params['scope'],
}
# 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
# Check if response_type must include id_token in the response.
if self.params['response_type'] in ['id_token', 'id_token token', 'code id_token', 'code id_token token']:
query_fragment['id_token'] = encode_id_token(id_token_dic, self.client)
else:
id_token_dic = {}
@ -165,18 +163,19 @@ class AuthorizeEndpoint(object):
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
# Code parameter must be present if it's Hybrid Flow.
if self.grant_type == 'hybrid':
query_fragment['code'] = code.code
query_fragment['state'] = self.params.state if self.params.state else ''
query_fragment['token_type'] = 'bearer'
query_fragment['expires_in'] = settings.get('OIDC_TOKEN_EXPIRE')
query_fragment['state'] = self.params['state'] if self.params['state'] else ''
except Exception as error:
logger.debug('[Authorize] Error when trying to create response uri: %s', error)
raise AuthorizeError(
self.params.redirect_uri,
'server_error',
self.grant_type)
raise AuthorizeError(self.params['redirect_uri'], 'server_error', self.grant_type)
uri = uri._replace(query=urlencode(query_params, doseq=True))
uri = uri._replace(fragment=urlencode(query_fragment, doseq=True))
@ -201,7 +200,7 @@ class AuthorizeEndpoint(object):
'date_given': date_given,
}
)
uc.scope = self.params.scope
uc.scope = self.params['scope']
# Rewrite expires_at and date_given if object already exists.
if not created:
@ -218,10 +217,8 @@ class AuthorizeEndpoint(object):
"""
value = False
try:
uc = UserConsent.objects.get(user=self.request.user,
client=self.client)
if (set(self.params.scope).issubset(uc.scope)) and \
not (uc.has_expired()):
uc = UserConsent.objects.get(user=self.request.user, client=self.client)
if (set(self.params['scope']).issubset(uc.scope)) and not (uc.has_expired()):
value = True
except UserConsent.DoesNotExist:
pass
@ -232,9 +229,9 @@ class AuthorizeEndpoint(object):
"""
Return a list with the description of all the scopes requested.
"""
scopes = StandardScopeClaims.get_scopes_info(self.params.scope)
scopes = StandardScopeClaims.get_scopes_info(self.params['scope'])
if settings.get('OIDC_EXTRA_SCOPE_CLAIMS'):
scopes_extra = settings.get('OIDC_EXTRA_SCOPE_CLAIMS', import_str=True).get_scopes_info(self.params.scope)
scopes_extra = settings.get('OIDC_EXTRA_SCOPE_CLAIMS', import_str=True).get_scopes_info(self.params['scope'])
for index_extra, scope_extra in enumerate(scopes_extra):
for index, scope in enumerate(scopes[:]):
if scope_extra['scope'] == scope['scope']:

View file

@ -12,7 +12,6 @@ from django.http import JsonResponse
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,
@ -33,23 +32,22 @@ class TokenEndpoint(object):
def __init__(self, request):
self.request = request
self.params = Params()
self.params = {}
self._extract_params()
def _extract_params(self):
client_id, client_secret = self._extract_client_auth()
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.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')
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['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 parameter.
self.params['code_verifier'] = self.request.POST.get('code_verifier')
def _extract_client_auth(self):
"""
@ -76,68 +74,68 @@ class TokenEndpoint(object):
def validate_params(self):
try:
self.client = Client.objects.get(client_id=self.params.client_id)
self.client = Client.objects.get(client_id=self.params['client_id'])
except Client.DoesNotExist:
logger.debug('[Token] Client does not exist: %s', self.params.client_id)
logger.debug('[Token] Client does not exist: %s', self.params['client_id'])
raise TokenError('invalid_client')
if self.client.client_type == 'confidential':
if not (self.client.client_secret == self.params.client_secret):
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):
logger.debug('[Token] Invalid redirect uri: %s', self.params.redirect_uri)
if self.params['grant_type'] == 'authorization_code':
if not (self.params['redirect_uri'] in self.client.redirect_uris):
logger.debug('[Token] Invalid redirect uri: %s', self.params['redirect_uri'])
raise TokenError('invalid_client')
try:
self.code = Code.objects.get(code=self.params.code)
self.code = Code.objects.get(code=self.params['code'])
except Code.DoesNotExist:
logger.debug('[Token] Code does not exist: %s', self.params.code)
logger.debug('[Token] Code does not exist: %s', self.params['code'])
raise TokenError('invalid_grant')
if not (self.code.client == self.client) \
or self.code.has_expired():
logger.debug('[Token] Invalid code: invalid client or code has expired',
self.params.redirect_uri)
self.params['redirect_uri'])
raise TokenError('invalid_grant')
# Validate PKCE parameters.
if self.params.code_verifier:
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()
hashlib.sha256(self.params['code_verifier'].encode('ascii')).digest()
).decode('utf-8').replace('=', '')
else:
new_code_challenge = self.params.code_verifier
new_code_challenge = self.params['code_verifier']
# TODO: We should explain the error.
if not (new_code_challenge == self.code.code_challenge):
raise TokenError('invalid_grant')
elif self.params.grant_type == 'refresh_token':
if not self.params.refresh_token:
elif self.params['grant_type'] == 'refresh_token':
if not self.params['refresh_token']:
logger.debug('[Token] Missing refresh token')
raise TokenError('invalid_grant')
try:
self.token = Token.objects.get(refresh_token=self.params.refresh_token,
self.token = Token.objects.get(refresh_token=self.params['refresh_token'],
client=self.client)
except Token.DoesNotExist:
logger.debug('[Token] Refresh token does not exist: %s', self.params.refresh_token)
logger.debug('[Token] Refresh token does not exist: %s', self.params['refresh_token'])
raise TokenError('invalid_grant')
else:
logger.debug('[Token] Invalid grant type: %s', self.params.grant_type)
logger.debug('[Token] Invalid grant type: %s', self.params['grant_type'])
raise TokenError('unsupported_grant_type')
def create_response_dic(self):
if self.params.grant_type == 'authorization_code':
if self.params['grant_type'] == 'authorization_code':
return self.create_code_response_dic()
elif self.params.grant_type == 'refresh_token':
elif self.params['grant_type'] == 'refresh_token':
return self.create_refresh_response_dic()
def create_code_response_dic(self):
@ -153,6 +151,7 @@ class TokenEndpoint(object):
nonce=self.code.nonce,
at_hash=token.at_hash,
request=self.request,
scope=self.params['scope'],
)
else:
id_token_dic = {}
@ -188,6 +187,7 @@ class TokenEndpoint(object):
nonce=None,
at_hash=token.at_hash,
request=self.request,
scope=self.params['scope'],
)
else:
id_token_dic = {}

View file

@ -42,7 +42,7 @@ def get_issuer(site_url=None, request=None):
.split('/.well-known/openid-configuration')[0]
issuer = site_url + path
return issuer
return str(issuer)
def default_userinfo(claims, user):

View file

@ -1,7 +0,0 @@
class Params(object):
"""
The purpose of this class is for accesing params via dot notation.
"""
pass

View file

@ -2,7 +2,7 @@ from datetime import timedelta
import time
import uuid
from Crypto.PublicKey.RSA import importKey
from Cryptodome.PublicKey.RSA import importKey
from django.utils import timezone
from jwkest.jwk import RSAKey as jwk_RSAKey
from jwkest.jwk import SYMKey
@ -17,10 +17,9 @@ from oidc_provider.models import (
from oidc_provider import settings
def create_id_token(user, aud, nonce, at_hash=None, request=None):
def create_id_token(user, aud, nonce, at_hash=None, request=None, scope=[]):
"""
Receives a user object and aud (audience).
Then creates the id_token dictionary.
Creates the id_token dictionary.
See: http://openid.net/specs/openid-connect-core-1_0.html#IDToken
Return a dic.
@ -51,6 +50,9 @@ def create_id_token(user, aud, nonce, at_hash=None, request=None):
if at_hash:
dic['at_hash'] = at_hash
if ('email' in scope) and getattr(user, 'email', None):
dic['email'] = user.email
processing_hook = settings.get('OIDC_IDTOKEN_PROCESSING_HOOK')
if isinstance(processing_hook, (list, tuple)):

View file

@ -1,6 +1,6 @@
import os
from Crypto.PublicKey import RSA
from Cryptodome.PublicKey import RSA
from django.core.management.base import BaseCommand
from oidc_provider import settings

View file

@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.7 on 2016-09-12 14:08
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('oidc_provider', '0017_auto_20160811_1954'),
]
operations = [
migrations.AddField(
model_name='client',
name='contact_email',
field=models.CharField(blank=True, default='', max_length=255, verbose_name='Contact Email'),
),
migrations.AddField(
model_name='client',
name='logo',
field=models.FileField(blank=True, default='', upload_to='oidc_provider/clients', verbose_name='Logo Image'),
),
migrations.AddField(
model_name='client',
name='terms_url',
field=models.CharField(blank=True, default='', help_text='External reference to the privacy policy of the client.', max_length=255, verbose_name='Terms URL'),
),
migrations.AddField(
model_name='client',
name='website_url',
field=models.CharField(blank=True, default='', max_length=255, verbose_name='Website URL'),
),
migrations.AlterField(
model_name='client',
name='jwt_alg',
field=models.CharField(choices=[('HS256', 'HS256'), ('RS256', 'RS256')], default='RS256', help_text='Algorithm used to encode ID Tokens.', max_length=10, verbose_name='JWT Algorithm'),
),
migrations.AlterField(
model_name='client',
name='response_type',
field=models.CharField(choices=[('code', 'code (Authorization Code Flow)'), ('id_token', 'id_token (Implicit Flow)'), ('id_token token', 'id_token token (Implicit Flow)'), ('code token', 'code token (Hybrid Flow)'), ('code id_token', 'code id_token (Hybrid Flow)'), ('code id_token token', 'code id_token token (Hybrid Flow)')], max_length=30, verbose_name='Response Type'),
),
]

View file

@ -19,6 +19,9 @@ RESPONSE_TYPE_CHOICES = [
('code', 'code (Authorization Code Flow)'),
('id_token', 'id_token (Implicit Flow)'),
('id_token token', 'id_token token (Implicit Flow)'),
('code token', 'code token (Hybrid Flow)'),
('code id_token', 'code id_token (Hybrid Flow)'),
('code id_token token', 'code id_token token (Hybrid Flow)'),
]
JWT_ALGS = [
@ -34,8 +37,12 @@ class Client(models.Model):
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'))
jwt_alg = models.CharField(max_length=10, choices=JWT_ALGS, default='RS256', verbose_name=_(u'JWT Algorithm'), help_text=_(u'Algorithm used to encode ID Tokens.'))
date_created = models.DateField(auto_now_add=True, verbose_name=_(u'Date Created'))
website_url = models.CharField(max_length=255, blank=True, default='', verbose_name=_(u'Website URL'))
terms_url = models.CharField(max_length=255, blank=True, default='', verbose_name=_(u'Terms URL'), help_text=_(u'External reference to the privacy policy of the client.'))
contact_email = models.CharField(max_length=255, blank=True, default='', verbose_name=_(u'Contact Email'))
logo = models.FileField(blank=True, default='', upload_to='oidc_provider/clients', verbose_name=_(u'Logo Image'))
_redirect_uris = models.TextField(default='', verbose_name=_(u'Redirect URIs'), help_text=_(u'Enter each URI on a new line.'))

View file

@ -60,15 +60,14 @@ def create_fake_client(response_type, is_public=False):
def is_code_valid(url, user, client):
"""
Check if the code inside the url is valid.
Check if the code inside the url is valid. Supporting both query string and fragment.
"""
try:
parsed = urlsplit(url)
params = parse_qs(parsed.query)
params = parse_qs(parsed.query or parsed.fragment)
code = params['code'][0]
code = Code.objects.get(code=code)
is_code_ok = (code.client == client) and \
(code.user == user)
is_code_ok = (code.client == client) and (code.user == user)
except:
is_code_ok = False

View file

@ -11,7 +11,10 @@ import uuid
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 (
RequestFactory,
override_settings,
)
from django.test import TestCase
from jwkest.jwt import JWT
@ -25,19 +28,7 @@ from oidc_provider.tests.app.utils import (
from oidc_provider.views import AuthorizeView
class AuthorizationCodeFlowTestCase(TestCase):
"""
Test cases for Authorize Endpoint using Code Flow.
"""
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.state = uuid.uuid4().hex
self.nonce = uuid.uuid4().hex
class AuthorizeEndpointMixin(object):
def _auth_request(self, method, data={}, is_user_authenticated=False):
url = reverse('oidc_provider:authorize')
@ -59,6 +50,21 @@ class AuthorizationCodeFlowTestCase(TestCase):
return response
class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin):
"""
Test cases for Authorize Endpoint using Code Flow.
"""
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.state = uuid.uuid4().hex
self.nonce = uuid.uuid4().hex
def test_missing_parameters(self):
"""
If the request fails due to a missing, invalid, or mismatching
@ -94,8 +100,7 @@ class AuthorizationCodeFlowTestCase(TestCase):
self.assertEqual(response.has_header('Location'), True)
# Should be an 'error' component in query.
query_exists = 'error=' in response['Location']
self.assertEqual(query_exists, True)
self.assertIn('error=', response['Location'])
def test_user_not_logged(self):
"""
@ -115,8 +120,7 @@ class AuthorizationCodeFlowTestCase(TestCase):
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']
self.assertEqual(login_url_exists, True)
self.assertIn(settings.get('LOGIN_URL'), response['Location'])
def test_user_consent_inputs(self):
"""
@ -183,10 +187,8 @@ class AuthorizationCodeFlowTestCase(TestCase):
# Because user doesn't allow app, SHOULD exists an error parameter
# in the query.
self.assertEqual('error=' in response['Location'], True,
msg='error param is missing in query.')
self.assertEqual('access_denied' in response['Location'], True,
msg='"access_denied" code is missing in query.')
self.assertIn('error=', response['Location'], msg='error param is missing in query.')
self.assertIn('access_denied', response['Location'], msg='"access_denied" code is missing in query.')
# Simulate user authorization.
data['allow'] = 'Accept' # Will be the value of the button.
@ -201,8 +203,7 @@ class AuthorizationCodeFlowTestCase(TestCase):
# Check if the state is returned.
state = (response['Location'].split('state='))[1].split('&')[0]
self.assertEqual(state == self.state, True,
msg='State change or is missing.')
self.assertEqual(state, self.state, msg='State change or is missing.')
def test_user_consent_skipped(self):
"""
@ -227,8 +228,7 @@ class AuthorizationCodeFlowTestCase(TestCase):
with self.settings(OIDC_SKIP_CONSENT_ALWAYS=True):
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.')
self.assertIn('code', response['Location'], msg='Code is missing in the returned url.')
response = self._auth_request('post', data, is_user_authenticated=True)
@ -274,7 +274,7 @@ class AuthorizationCodeFlowTestCase(TestCase):
with self.settings(OIDC_SKIP_CONSENT_ALWAYS=True):
response = self._auth_request('get', data, is_user_authenticated=True)
self.assertEqual('Request for Permission' in response.content.decode('utf-8'), True)
self.assertIn('Request for Permission', response.content.decode('utf-8'))
def test_prompt_parameter(self):
"""
@ -294,15 +294,15 @@ class AuthorizationCodeFlowTestCase(TestCase):
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)
self.assertIn('login_required', response['Location'])
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)
self.assertIn('interaction_required', response['Location'])
class AuthorizationImplicitFlowTestCase(TestCase):
class AuthorizationImplicitFlowTestCase(TestCase, AuthorizeEndpointMixin):
"""
Test cases for Authorization Endpoint using Implicit Flow.
"""
@ -318,26 +318,6 @@ class AuthorizationImplicitFlowTestCase(TestCase):
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.
@ -352,9 +332,9 @@ class AuthorizationImplicitFlowTestCase(TestCase):
response = self._auth_request('get', data, is_user_authenticated=True)
self.assertEqual('#error=invalid_request' in response['Location'], True)
self.assertIn('#error=invalid_request', response['Location'])
def test_id_token_token_response(self):
def test_idtoken_token_response(self):
"""
Implicit client requesting `id_token token` receives both id token
and access token as the result of the authorization request.
@ -384,7 +364,7 @@ class AuthorizationImplicitFlowTestCase(TestCase):
self.assertIn('access_token', response['Location'])
self.assertIn('id_token', response['Location'])
def test_id_token_response(self):
def test_idtoken_response(self):
"""
Implicit client requesting `id_token` receives
only an id token as the result of the authorization request.
@ -414,7 +394,7 @@ class AuthorizationImplicitFlowTestCase(TestCase):
self.assertNotIn('access_token', response['Location'])
self.assertIn('id_token', response['Location'])
def test_id_token_token_at_hash(self):
def test_idtoken_token_at_hash(self):
"""
Implicit client requesting `id_token token` receives
`at_hash` in `id_token`.
@ -440,7 +420,7 @@ class AuthorizationImplicitFlowTestCase(TestCase):
self.assertIn('at_hash', id_token)
def test_id_token_at_hash(self):
def test_idtoken_at_hash(self):
"""
Implicit client requesting `id_token` should not receive
`at_hash` in `id_token`.
@ -465,3 +445,56 @@ class AuthorizationImplicitFlowTestCase(TestCase):
id_token = JWT().unpack(fragment["id_token"][0].encode('utf-8')).payload()
self.assertNotIn('at_hash', id_token)
class AuthorizationHybridFlowTestCase(TestCase, AuthorizeEndpointMixin):
"""
Test cases for Authorization Endpoint using Hybrid Flow.
"""
def setUp(self):
call_command('creatersakey')
self.factory = RequestFactory()
self.user = create_fake_user()
self.client_code_idtoken_token = create_fake_client(response_type='code id_token token', is_public=True)
self.state = uuid.uuid4().hex
self.nonce = uuid.uuid4().hex
# Base data for the auth request.
self.data = {
'client_id': self.client_code_idtoken_token.client_id,
'redirect_uri': self.client_code_idtoken_token.default_redirect_uri,
'response_type': self.client_code_idtoken_token.response_type,
'scope': 'openid email',
'state': self.state,
'nonce': self.nonce,
'allow': 'Accept',
}
def test_code_idtoken_token_response(self):
"""
Implicit client requesting `id_token token` receives both id token
and access token as the result of the authorization request.
"""
response = self._auth_request('post', self.data, is_user_authenticated=True)
self.assertIn('#', response['Location'])
self.assertIn('access_token', response['Location'])
self.assertIn('id_token', response['Location'])
self.assertIn('state', response['Location'])
self.assertIn('code', response['Location'])
# Validate code.
is_code_ok = is_code_valid(url=response['Location'],
user=self.user,
client=self.client_code_idtoken_token)
self.assertEqual(is_code_ok, True, msg='Code returned is invalid.')
@override_settings(OIDC_TOKEN_EXPIRE=36000)
def test_access_token_expiration(self):
"""
Add ten hours of expiration to access_token. Check for the expires_in query in fragment.
"""
response = self._auth_request('post', self.data, is_user_authenticated=True)
self.assertIn('expires_in=36000', response['Location'])

View file

@ -10,7 +10,10 @@ except ImportError:
from django.core.management import call_command
from django.core.urlresolvers import reverse
from django.test import RequestFactory, override_settings
from django.test import (
RequestFactory,
override_settings,
)
from django.test import TestCase
from django.utils import timezone
from jwkest.jwk import KEYS
@ -148,7 +151,7 @@ class TokenTestCase(TestCase):
self.assertEqual(response_dic['token_type'], 'bearer')
self.assertEqual(response_dic['expires_in'], 720)
self.assertEqual(id_token['sub'], str(self.user.id))
self.assertEqual(id_token['aud'], self.client.client_id)
self.assertEqual(id_token['aud'], self.client.client_id);
def test_refresh_token(self):
"""

View file

@ -34,14 +34,20 @@ class UserInfoTestCase(TestCase):
"""
Generate a valid token.
"""
id_token_dic = create_id_token(self.user,
self.client.client_id, FAKE_NONCE)
scope = ['openid', 'email'] + extra_scope
id_token_dic = create_id_token(
user=self.user,
aud=self.client.client_id,
nonce=FAKE_NONCE,
scope=scope,
)
token = create_token(
user=self.user,
client=self.client,
id_token_dic=id_token_dic,
scope=['openid', 'email'] + extra_scope)
scope=scope)
token.save()
return token

View file

@ -1,6 +1,6 @@
import logging
from Crypto.PublicKey import RSA
from Cryptodome.PublicKey import RSA
from django.contrib.auth.views import redirect_to_login, logout
from django.core.urlresolvers import reverse
from django.http import JsonResponse
@ -46,24 +46,24 @@ class AuthorizeView(View):
return hook_resp
if settings.get('OIDC_SKIP_CONSENT_ALWAYS') and not (authorize.client.client_type == 'public') \
and not (authorize.params.prompt == 'consent'):
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') \
and not (authorize.params.prompt == 'consent'):
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'] == 'none':
raise AuthorizeError(authorize.params['redirect_uri'], 'interaction_required', authorize.grant_type)
if authorize.params.prompt == 'login':
if authorize.params['prompt'] == 'login':
return redirect_to_login(request.get_full_path())
if authorize.params.prompt == 'select_account':
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)
raise AuthorizeError(authorize.params['redirect_uri'], 'account_selection_required', authorize.grant_type)
# Generate hidden inputs for the form.
context = {
@ -73,8 +73,8 @@ class AuthorizeView(View):
# Remove `openid` from scope list
# since we don't need to print it.
if 'openid' in authorize.params.scope:
authorize.params.scope.remove('openid')
if 'openid' in authorize.params['scope']:
authorize.params['scope'].remove('openid')
context = {
'client': authorize.client,
@ -85,8 +85,8 @@ class AuthorizeView(View):
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)
if authorize.params['prompt'] == 'none':
raise AuthorizeError(authorize.params['redirect_uri'], 'login_required', authorize.grant_type)
return redirect_to_login(request.get_full_path())
@ -100,8 +100,8 @@ class AuthorizeView(View):
except (AuthorizeError) as error:
uri = error.create_uri(
authorize.params.redirect_uri,
authorize.params.state)
authorize.params['redirect_uri'],
authorize.params['state'])
return redirect(uri)
@ -112,7 +112,7 @@ class AuthorizeView(View):
authorize.validate_params()
if not request.POST.get('allow'):
raise AuthorizeError(authorize.params.redirect_uri,
raise AuthorizeError(authorize.params['redirect_uri'],
'access_denied',
authorize.grant_type)
@ -125,8 +125,8 @@ class AuthorizeView(View):
except (AuthorizeError) as error:
uri = error.create_uri(
authorize.params.redirect_uri,
authorize.params.state)
authorize.params['redirect_uri'],
authorize.params['state'])
return redirect(uri)
@ -134,7 +134,6 @@ class AuthorizeView(View):
class TokenView(View):
def post(self, request, *args, **kwargs):
token = TokenEndpoint(request)
try:

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.7',
version='0.4.1',
packages=[
'oidc_provider', 'oidc_provider/lib', 'oidc_provider/lib/endpoints',
'oidc_provider/lib/utils', 'oidc_provider/tests', 'oidc_provider/tests/app',
@ -36,11 +36,11 @@ setup(
],
test_suite='runtests.runtests',
tests_require=[
'pyjwkest==1.1.0',
'pyjwkest==1.3.0',
'mock==2.0.0',
],
install_requires=[
'pyjwkest==1.1.0',
'pyjwkest==1.3.0',
],
)