Merge branch 'v0.3.x' of https://github.com/juanifioren/django-oidc-provider into v0.3.x
Conflicts: example_project/provider_app/templates/base.html example_project/provider_app/templates/login.html example_project/provider_app/templates/oidc_provider/authorize.html example_project/provider_app/templates/oidc_provider/error.html
This commit is contained in:
commit
7f5f1eb584
24 changed files with 379 additions and 239 deletions
|
@ -2,10 +2,15 @@ language: python
|
|||
python:
|
||||
- "2.7"
|
||||
- "3.4"
|
||||
- "3.5"
|
||||
env:
|
||||
- DJANGO=1.7
|
||||
- DJANGO=1.8
|
||||
- DJANGO=1.9
|
||||
matrix:
|
||||
exclude:
|
||||
- python: "3.5"
|
||||
env: DJANGO=1.7
|
||||
install:
|
||||
- pip install -q django==$DJANGO
|
||||
- pip install -e .
|
||||
|
|
|
@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
### [Unreleased]
|
||||
|
||||
##### Added
|
||||
- Choose type of client on creation.
|
||||
- Implement Proof Key for Code Exchange by OAuth Public Clients.
|
||||
- Support for prompt parameter.
|
||||
|
||||
##### Fixed
|
||||
- Not auto-approve requests for non-confidential clients (publics).
|
||||
|
||||
### [0.3.1] - 2016-03-09
|
||||
|
||||
##### Fixed
|
||||
|
|
|
@ -53,9 +53,9 @@ author = u'Juan Ignacio Fiorentino'
|
|||
# built documents.
|
||||
#
|
||||
# The short X.Y version.
|
||||
version = u'0.2'
|
||||
version = u'0.3'
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = u'0.2.5'
|
||||
release = u'0.3.x'
|
||||
|
||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||
# for a list of supported languages.
|
||||
|
|
BIN
docs/images/client_creation.png
Normal file
BIN
docs/images/client_creation.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 50 KiB |
|
@ -3,6 +3,11 @@ Welcome to Django OIDC Provider Documentation!
|
|||
|
||||
Django OIDC Provider can help you providing out of the box all the endpoints, data and logic needed to add OpenID Connect capabilities to your Django projects. And as a side effect a fair implementation of OAuth2.0 too.
|
||||
|
||||
Also implements the following specifications:
|
||||
|
||||
* `OAuth 2.0 for Native Apps <https://tools.ietf.org/html/draft-ietf-oauth-native-apps-01>`_
|
||||
* `Proof Key for Code Exchange by OAuth Public Clients <https://tools.ietf.org/html/rfc7636>`_
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
Before getting started there are some important things that you should know:
|
||||
|
@ -19,7 +24,7 @@ Contents:
|
|||
:maxdepth: 2
|
||||
|
||||
sections/installation
|
||||
sections/clients
|
||||
sections/relyingparties
|
||||
sections/serverkeys
|
||||
sections/templates
|
||||
sections/claims
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
.. _clients:
|
||||
|
||||
Clients
|
||||
#######
|
||||
|
||||
Also known as Relying Parties (RP). User and client creation it's up to you. This is because is out of the scope in the core implementation of OIDC.
|
||||
So, there are different ways to create your Clients. By displaying a HTML form or maybe if you have internal thrusted Clients you can create them programatically.
|
||||
|
||||
`Read more about client creation from OAuth2 spec <http://tools.ietf.org/html/rfc6749#section-2>`_
|
||||
|
||||
For your users, the tipical situation is that you provide them a login and a registration page.
|
||||
|
||||
If you want to test the provider without getting to deep into this topics you can:
|
||||
|
||||
Create a user with ``python manage.py createsuperuser`` and clients using Django admin:
|
||||
|
||||
.. image:: http://i64.tinypic.com/2dsfgoy.png
|
||||
:align: center
|
||||
|
||||
Or also you can create a client programmatically with Django shell ``python manage.py shell``::
|
||||
|
||||
>>> from oidc_provider.models import Client
|
||||
>>> c = Client(name='Some Client', client_id='123', client_secret='456', response_type='code', redirect_uris=['http://example.com/'])
|
||||
>>> c.save()
|
43
docs/sections/relyingparties.rst
Normal file
43
docs/sections/relyingparties.rst
Normal file
|
@ -0,0 +1,43 @@
|
|||
.. _relyingparties:
|
||||
|
||||
Relying Parties
|
||||
###############
|
||||
|
||||
Relying Parties (RP) creation it's up to you. This is because is out of the scope in the core implementation of OIDC.
|
||||
So, there are different ways to create your Clients (RP). By displaying a HTML form or maybe if you have internal thrusted Clients you can create them programatically.
|
||||
|
||||
OAuth defines two client types, based on their ability to maintain the confidentiality of their client credentials:
|
||||
|
||||
* ``confidential``: Clients capable of maintaining the confidentiality of their credentials (e.g., client implemented on a secure server with restricted access to the client credentials).
|
||||
* ``public``: Clients incapable of maintaining the confidentiality of their credentials (e.g., clients executing on the device used by the resource owner, such as an installed native application or a web browser-based application), and incapable of secure client authentication via any other means.
|
||||
|
||||
Using the admin
|
||||
===============
|
||||
|
||||
We suggest you to use Django admin to easily manage your clients:
|
||||
|
||||
.. image:: ../images/client_creation.png
|
||||
:align: center
|
||||
|
||||
For re-generating ``client_secret``, when you are in the Client editing view, select "Client type" to be ``public``. Then after saving, select back to be ``confidential`` and save again.
|
||||
|
||||
Custom view
|
||||
===========
|
||||
|
||||
If for some reason you need to create your own view to manage them, you can grab the form class that the admin makes use of. Located in ``oidc_provider.admin.ClientForm``.
|
||||
|
||||
Some built-in logic that comes with it:
|
||||
|
||||
* Automatic ``client_id`` and ``client_secret`` generation.
|
||||
* Empty ``client_secret`` when ``client_type`` is equal to ``public``.
|
||||
|
||||
Programmatically
|
||||
================
|
||||
|
||||
You can create a Client programmatically with Django shell ``python manage.py shell``::
|
||||
|
||||
>>> from oidc_provider.models import Client
|
||||
>>> c = Client(name='Some Client', client_id='123', client_secret='456', response_type='code', redirect_uris=['http://example.com/'])
|
||||
>>> c.save()
|
||||
|
||||
`Read more about client creation from OAuth2 spec <http://tools.ietf.org/html/rfc6749#section-2>`_
|
2
example_project/.gitignore
vendored
2
example_project/.gitignore
vendored
|
@ -1,3 +1,3 @@
|
|||
*.sqlite3
|
||||
*.pem
|
||||
|
||||
static/
|
||||
|
|
|
@ -9,7 +9,7 @@ urlpatterns = [
|
|||
url(r'^accounts/login/$', auth_views.login, { 'template_name': 'login.html' }, name='login'),
|
||||
url(r'^accounts/logout/$', auth_views.logout, { 'next_page': '/' }, name='logout'),
|
||||
|
||||
url(r'^openid/', include('oidc_provider.urls', namespace='oidc_provider')),
|
||||
url(r'^', include('oidc_provider.urls', namespace='oidc_provider')),
|
||||
|
||||
url(r'^admin/', include(admin.site.urls)),
|
||||
]
|
||||
|
|
|
@ -30,10 +30,19 @@ class ClientForm(ModelForm):
|
|||
|
||||
def clean_client_secret(self):
|
||||
instance = getattr(self, 'instance', None)
|
||||
|
||||
secret = ''
|
||||
|
||||
if instance and instance.pk:
|
||||
return instance.client_secret
|
||||
if (self.cleaned_data['client_type'] == 'confidential') and not instance.client_secret:
|
||||
secret = md5(uuid4().hex.encode()).hexdigest()
|
||||
elif (self.cleaned_data['client_type'] == 'confidential') and instance.client_secret:
|
||||
secret = instance.client_secret
|
||||
else:
|
||||
return md5(uuid4().hex.encode()).hexdigest()
|
||||
if (instance.client_type == 'confidential'):
|
||||
secret = md5(uuid4().hex.encode()).hexdigest()
|
||||
|
||||
return secret
|
||||
|
||||
|
||||
@admin.register(Client)
|
||||
|
|
|
@ -55,37 +55,50 @@ class AuthorizeEndpoint(object):
|
|||
self.params.scope = query_dict.get('scope', '').split()
|
||||
self.params.state = query_dict.get('state', '')
|
||||
self.params.nonce = query_dict.get('nonce', '')
|
||||
self.params.prompt = query_dict.get('prompt', '')
|
||||
|
||||
# PKCE parameters.
|
||||
self.params.code_challenge = query_dict.get('code_challenge')
|
||||
self.params.code_challenge_method = query_dict.get('code_challenge_method')
|
||||
|
||||
def validate_params(self):
|
||||
# Client validation.
|
||||
try:
|
||||
self.client = Client.objects.get(client_id=self.params.client_id)
|
||||
except Client.DoesNotExist:
|
||||
logger.debug('[Authorize] Invalid client identifier: %s', self.params.client_id)
|
||||
raise ClientIdError()
|
||||
|
||||
# Redirect URI validation.
|
||||
if self.is_authentication and not self.params.redirect_uri:
|
||||
logger.debug('[Authorize] Missing redirect uri.')
|
||||
raise RedirectUriError()
|
||||
|
||||
if not self.grant_type:
|
||||
logger.debug('[Authorize] Invalid response type: %s', self.params.response_type)
|
||||
raise AuthorizeError(self.params.redirect_uri, 'unsupported_response_type',
|
||||
self.grant_type)
|
||||
|
||||
if self.is_authentication and self.grant_type == 'implicit' and not self.params.nonce:
|
||||
raise AuthorizeError(self.params.redirect_uri, 'invalid_request',
|
||||
self.grant_type)
|
||||
|
||||
if self.is_authentication and self.params.response_type != self.client.response_type:
|
||||
raise AuthorizeError(self.params.redirect_uri, 'invalid_request',
|
||||
self.grant_type)
|
||||
|
||||
clean_redirect_uri = urlsplit(self.params.redirect_uri)
|
||||
clean_redirect_uri = urlunsplit(clean_redirect_uri._replace(query=''))
|
||||
if not (clean_redirect_uri in self.client.redirect_uris):
|
||||
logger.debug('[Authorize] Invalid redirect uri: %s', self.params.redirect_uri)
|
||||
raise RedirectUriError()
|
||||
|
||||
# Grant type validation.
|
||||
if not self.grant_type:
|
||||
logger.debug('[Authorize] Invalid response type: %s', self.params.response_type)
|
||||
raise AuthorizeError(self.params.redirect_uri, 'unsupported_response_type',
|
||||
self.grant_type)
|
||||
|
||||
# Nonce parameter validation.
|
||||
if self.is_authentication and self.grant_type == 'implicit' and not self.params.nonce:
|
||||
raise AuthorizeError(self.params.redirect_uri, 'invalid_request',
|
||||
self.grant_type)
|
||||
|
||||
# Response type parameter validation.
|
||||
if self.is_authentication and self.params.response_type != self.client.response_type:
|
||||
raise AuthorizeError(self.params.redirect_uri, 'invalid_request',
|
||||
self.grant_type)
|
||||
|
||||
# PKCE validation of the transformation method.
|
||||
if self.params.code_challenge:
|
||||
if not (self.params.code_challenge_method in ['plain', 'S256']):
|
||||
raise AuthorizeError(self.params.redirect_uri, 'invalid_request', self.grant_type)
|
||||
|
||||
def create_response_uri(self):
|
||||
uri = urlsplit(self.params.redirect_uri)
|
||||
|
@ -99,7 +112,9 @@ class AuthorizeEndpoint(object):
|
|||
client=self.client,
|
||||
scope=self.params.scope,
|
||||
nonce=self.params.nonce,
|
||||
is_authentication=self.is_authentication)
|
||||
is_authentication=self.is_authentication,
|
||||
code_challenge=self.params.code_challenge,
|
||||
code_challenge_method=self.params.code_challenge_method)
|
||||
|
||||
code.save()
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from base64 import b64decode
|
||||
from base64 import b64decode, urlsafe_b64decode, urlsafe_b64encode
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
try:
|
||||
|
@ -6,7 +7,9 @@ try:
|
|||
except ImportError:
|
||||
from urllib import unquote
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
from django.http import JsonResponse
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
from oidc_provider.lib.errors import *
|
||||
from oidc_provider.lib.utils.params import *
|
||||
|
@ -30,14 +33,16 @@ class TokenEndpoint(object):
|
|||
|
||||
self.params.client_id = client_id
|
||||
self.params.client_secret = client_secret
|
||||
self.params.redirect_uri = unquote(
|
||||
self.request.POST.get('redirect_uri', ''))
|
||||
self.params.redirect_uri = unquote(self.request.POST.get('redirect_uri', ''))
|
||||
self.params.grant_type = self.request.POST.get('grant_type', '')
|
||||
self.params.code = self.request.POST.get('code', '')
|
||||
self.params.state = self.request.POST.get('state', '')
|
||||
self.params.scope = self.request.POST.get('scope', '')
|
||||
self.params.refresh_token = self.request.POST.get('refresh_token', '')
|
||||
|
||||
# PKCE parameters.
|
||||
self.params.code_verifier = self.request.POST.get('code_verifier')
|
||||
|
||||
def _extract_client_auth(self):
|
||||
"""
|
||||
Get client credentials using HTTP Basic Authentication method.
|
||||
|
@ -68,10 +73,11 @@ class TokenEndpoint(object):
|
|||
logger.debug('[Token] Client does not exist: %s', self.params.client_id)
|
||||
raise TokenError('invalid_client')
|
||||
|
||||
if not (self.client.client_secret == self.params.client_secret):
|
||||
logger.debug('[Token] Invalid client secret: client %s do not have secret %s',
|
||||
self.client.client_id, self.client.client_secret)
|
||||
raise TokenError('invalid_client')
|
||||
if self.client.client_type == 'confidential':
|
||||
if not (self.client.client_secret == self.params.client_secret):
|
||||
logger.debug('[Token] Invalid client secret: client %s do not have secret %s',
|
||||
self.client.client_id, self.client.client_secret)
|
||||
raise TokenError('invalid_client')
|
||||
|
||||
if self.params.grant_type == 'authorization_code':
|
||||
if not (self.params.redirect_uri in self.client.redirect_uris):
|
||||
|
@ -90,6 +96,19 @@ class TokenEndpoint(object):
|
|||
self.params.redirect_uri)
|
||||
raise TokenError('invalid_grant')
|
||||
|
||||
# Validate PKCE parameters.
|
||||
if self.params.code_verifier:
|
||||
if self.code.code_challenge_method == 'S256':
|
||||
new_code_challenge = urlsafe_b64encode(
|
||||
hashlib.sha256(self.params.code_verifier.encode('ascii')).digest()
|
||||
).decode('utf-8').replace('=', '')
|
||||
else:
|
||||
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:
|
||||
logger.debug('[Token] Missing refresh token')
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
from base64 import urlsafe_b64decode, urlsafe_b64encode
|
||||
from datetime import timedelta
|
||||
import time
|
||||
import uuid
|
||||
|
@ -95,7 +96,8 @@ def create_token(user, client, id_token_dic, scope):
|
|||
return token
|
||||
|
||||
|
||||
def create_code(user, client, scope, nonce, is_authentication):
|
||||
def create_code(user, client, scope, nonce, is_authentication,
|
||||
code_challenge=None, code_challenge_method=None):
|
||||
"""
|
||||
Create and populate a Code object.
|
||||
|
||||
|
@ -104,7 +106,13 @@ def create_code(user, client, scope, nonce, is_authentication):
|
|||
code = Code()
|
||||
code.user = user
|
||||
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
|
||||
|
||||
code.expires_at = timezone.now() + timedelta(
|
||||
seconds=settings.get('OIDC_CODE_EXPIRE'))
|
||||
code.scope = scope
|
||||
|
|
20
oidc_provider/migrations/0011_client_client_type.py
Normal file
20
oidc_provider/migrations/0011_client_client_type.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9 on 2016-04-04 19:56
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('oidc_provider', '0010_code_is_authentication'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='client',
|
||||
name='client_type',
|
||||
field=models.CharField(choices=[(b'confidential', b'Confidential'), (b'public', b'Public')], default=b'confidential', help_text='<b>Confidential</b> clients are capable of maintaining the confidentiality of their credentials. <b>Public</b> clients are incapable.', max_length=30),
|
||||
),
|
||||
]
|
20
oidc_provider/migrations/0012_auto_20160405_2041.py
Normal file
20
oidc_provider/migrations/0012_auto_20160405_2041.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9 on 2016-04-05 20:41
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('oidc_provider', '0011_client_client_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='client',
|
||||
name='client_secret',
|
||||
field=models.CharField(blank=True, default=b'', max_length=255),
|
||||
),
|
||||
]
|
25
oidc_provider/migrations/0013_auto_20160407_1912.py
Normal file
25
oidc_provider/migrations/0013_auto_20160407_1912.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9 on 2016-04-07 19:12
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('oidc_provider', '0012_auto_20160405_2041'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='code',
|
||||
name='code_challenge',
|
||||
field=models.CharField(max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='code',
|
||||
name='code_challenge_method',
|
||||
field=models.CharField(max_length=255, null=True),
|
||||
),
|
||||
]
|
|
@ -10,6 +10,11 @@ from django.conf import settings
|
|||
|
||||
class Client(models.Model):
|
||||
|
||||
CLIENT_TYPE_CHOICES = [
|
||||
('confidential', 'Confidential'),
|
||||
('public', 'Public'),
|
||||
]
|
||||
|
||||
RESPONSE_TYPE_CHOICES = [
|
||||
('code', 'code (Authorization Code Flow)'),
|
||||
('id_token', 'id_token (Implicit Flow)'),
|
||||
|
@ -17,10 +22,10 @@ class Client(models.Model):
|
|||
]
|
||||
|
||||
name = models.CharField(max_length=100, default='')
|
||||
client_type = models.CharField(max_length=30, choices=CLIENT_TYPE_CHOICES, default='confidential', help_text=_(u'<b>Confidential</b> clients are capable of maintaining the confidentiality of their credentials. <b>Public</b> clients are incapable.'))
|
||||
client_id = models.CharField(max_length=255, unique=True)
|
||||
client_secret = models.CharField(max_length=255, unique=True)
|
||||
response_type = models.CharField(max_length=30,
|
||||
choices=RESPONSE_TYPE_CHOICES)
|
||||
client_secret = models.CharField(max_length=255, blank=True, default='')
|
||||
response_type = models.CharField(max_length=30, choices=RESPONSE_TYPE_CHOICES)
|
||||
date_created = models.DateField(auto_now_add=True)
|
||||
|
||||
_redirect_uris = models.TextField(default='', verbose_name=_(u'Redirect URI'), help_text=_(u'Enter each URI on a new line.'))
|
||||
|
@ -81,6 +86,8 @@ class Code(BaseCodeTokenModel):
|
|||
code = models.CharField(max_length=255, unique=True)
|
||||
nonce = models.CharField(max_length=255, blank=True, default='')
|
||||
is_authentication = models.BooleanField(default=False)
|
||||
code_challenge = models.CharField(max_length=255, null=True)
|
||||
code_challenge_method = models.CharField(max_length=255, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _(u'Authorization Code')
|
||||
|
|
|
@ -4,3 +4,5 @@
|
|||
<input name="scope" type="hidden" value="{{ params.scope | join:' ' }}" />
|
||||
<input name="state" type="hidden" value="{{ params.state }}" />
|
||||
<input name="nonce" type="hidden" value="{{ params.nonce }}" />
|
||||
<input name="code_challenge" type="hidden" value="{{ params.code_challenge }}" />
|
||||
<input name="code_challenge_method" type="hidden" value="{{ params.code_challenge_method }}" />
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIICXgIBAAKBgQC/O5N0BxpMVbht7i0bFIQyD0q2O4mutyYLoAQn8skYEbDUmcwp
|
||||
9dRe7GTHiDrMqJ3gW9hTZcYm7dt5rhjFqdCYK504PDOcK8LGkCN2CiWeRbCAwaz0
|
||||
Wgh3oJfbTMuYV+LWLFAAPxN4cyN6RoE9mlk7vq7YNYVpdg0VNMAKvW95dQIDAQAB
|
||||
AoGBAIBMdxw0G7e1Fxxh3E87z4lKaySiAzh91f+cps0qfTIxxEKOwMQyEv5weRjJ
|
||||
VDG0ut8on5UsReoeUM5tOF99E92pEnenI7+VfnFf04xCLcdT0XGbKimb+5g6y1Pm
|
||||
8630TD97tVO0ASHcrXOtkSTYNdAUDcqeJUTOwgW0OD3Hyb8BAkEAxODr/Mln86wu
|
||||
NhnxEVf9wuEJxX6JUjnkh62wIWYbZU61D+pIrtofi/0+AYn/9IeBCTDNIM4qTzsC
|
||||
HV/u/3nmwQJBAPiooD4FYBI1VOwZ7RZqR0ZyQN0IkBsfw95K789I1lBeXh34b6r6
|
||||
dik4A72guaAZEuxTz3MPjbSrflGjq47fE7UCQQCPsDSrpvcGYbjMZXyKkvSywXlX
|
||||
OXXRnE0NNReiGJqQArSk6/GmI634hpg1mVlER41GfuaHNdCtSLzPYY/Vx0tBAkAc
|
||||
QFxkb4voxbJuWMu9HjoW4OhJtK1ax5MjcHQqouXmn7IlyZI2ZNqD+F9Ebjxo2jBy
|
||||
NVt+gSfifRGPCP927hV5AkEAwFu9HZipddp8PM8tyF1G09+s3DVSCR3DLMBwX9NX
|
||||
nGA9tOLYOSgG/HKLOWD1qT0G8r/vYtFuktCKMSidVMp5sw==
|
||||
-----END RSA PRIVATE KEY-----
|
|
@ -13,6 +13,8 @@ from oidc_provider.models import *
|
|||
|
||||
FAKE_NONCE = 'cb584e44c43ed6bd0bc2d9c7e242837d'
|
||||
FAKE_RANDOM_STRING = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(32))
|
||||
FAKE_CODE_CHALLENGE = 'YlYXEqXuRm-Xgi2BOUiK50JW1KsGTX6F1TDnZSC8VTg'
|
||||
FAKE_CODE_VERIFIER = 'SmxGa0XueyNh5bDgTcSrqzAh2_FmXEqU8kDT6CuXicw'
|
||||
|
||||
|
||||
def create_fake_user():
|
||||
|
@ -31,7 +33,7 @@ def create_fake_user():
|
|||
return user
|
||||
|
||||
|
||||
def create_fake_client(response_type):
|
||||
def create_fake_client(response_type, is_public=False):
|
||||
"""
|
||||
Create a test client, response_type argument MUST be:
|
||||
'code', 'id_token' or 'id_token token'.
|
||||
|
@ -40,8 +42,12 @@ def create_fake_client(response_type):
|
|||
"""
|
||||
client = Client()
|
||||
client.name = 'Some Client'
|
||||
client.client_id = '123'
|
||||
client.client_secret = '456'
|
||||
client.client_id = str(random.randint(1, 999999)).zfill(6)
|
||||
if is_public:
|
||||
client.client_type = 'public'
|
||||
client.client_secret = ''
|
||||
else:
|
||||
client.client_secret = str(random.randint(1, 999999)).zfill(6)
|
||||
client.response_type = response_type
|
||||
client.redirect_uris = ['http://example.com/']
|
||||
|
||||
|
@ -50,17 +56,6 @@ def create_fake_client(response_type):
|
|||
return client
|
||||
|
||||
|
||||
def create_rsakey():
|
||||
"""
|
||||
Generate and save a sample RSA Key.
|
||||
"""
|
||||
fullpath = os.path.abspath(os.path.dirname(__file__)) + '/RSAKEY.pem'
|
||||
|
||||
with open(fullpath, 'r') as f:
|
||||
key = f.read()
|
||||
RSAKey(key=key).save()
|
||||
|
||||
|
||||
def is_code_valid(url, user, client):
|
||||
"""
|
||||
Check if the code inside the url is valid.
|
||||
|
|
|
@ -6,6 +6,7 @@ import uuid
|
|||
|
||||
from django.contrib.auth import REDIRECT_FIELD_NAME
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.management import call_command
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.test import RequestFactory
|
||||
from django.test import TestCase
|
||||
|
@ -22,10 +23,35 @@ class AuthorizationCodeFlowTestCase(TestCase):
|
|||
"""
|
||||
|
||||
def setUp(self):
|
||||
call_command('creatersakey')
|
||||
self.factory = RequestFactory()
|
||||
self.user = create_fake_user()
|
||||
self.client = create_fake_client(response_type='code')
|
||||
self.client_public = create_fake_client(response_type='code', is_public=True)
|
||||
self.client_implicit = create_fake_client(response_type='id_token token')
|
||||
self.state = uuid.uuid4().hex
|
||||
self.nonce = uuid.uuid4().hex
|
||||
|
||||
def _auth_request(self, method, 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_parameters(self):
|
||||
"""
|
||||
|
@ -35,11 +61,7 @@ class AuthorizationCodeFlowTestCase(TestCase):
|
|||
|
||||
See: https://tools.ietf.org/html/rfc6749#section-4.1.2.1
|
||||
"""
|
||||
url = reverse('oidc_provider:authorize')
|
||||
|
||||
request = self.factory.get(url)
|
||||
|
||||
response = AuthorizeView.as_view()(request)
|
||||
response = self._auth_request('get')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(bool(response.content), True)
|
||||
|
@ -52,19 +74,15 @@ class AuthorizationCodeFlowTestCase(TestCase):
|
|||
See: http://openid.net/specs/openid-connect-core-1_0.html#AuthError
|
||||
"""
|
||||
# Create an authorize request with an unsupported response_type.
|
||||
query_str = urlencode({
|
||||
data = {
|
||||
'client_id': self.client.client_id,
|
||||
'response_type': 'something_wrong',
|
||||
'redirect_uri': self.client.default_redirect_uri,
|
||||
'scope': 'openid email',
|
||||
'state': self.state,
|
||||
}).replace('+', '%20')
|
||||
}
|
||||
|
||||
url = reverse('oidc_provider:authorize') + '?' + query_str
|
||||
|
||||
request = self.factory.get(url)
|
||||
|
||||
response = AuthorizeView.as_view()(request)
|
||||
response = self._auth_request('get', data)
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.has_header('Location'), True)
|
||||
|
@ -80,34 +98,20 @@ class AuthorizationCodeFlowTestCase(TestCase):
|
|||
|
||||
See: http://openid.net/specs/openid-connect-core-1_0.html#Authenticates
|
||||
"""
|
||||
query_str = urlencode({
|
||||
data = {
|
||||
'client_id': self.client.client_id,
|
||||
'response_type': 'code',
|
||||
'redirect_uri': self.client.default_redirect_uri,
|
||||
'scope': 'openid email',
|
||||
'state': self.state,
|
||||
}).replace('+', '%20')
|
||||
}
|
||||
|
||||
url = reverse('oidc_provider:authorize') + '?' + query_str
|
||||
|
||||
request = self.factory.get(url)
|
||||
request.user = AnonymousUser()
|
||||
|
||||
response = AuthorizeView.as_view()(request)
|
||||
response = self._auth_request('get', 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)
|
||||
|
||||
# Check if the login will redirect to a valid url.
|
||||
try:
|
||||
next_value = response['Location'].split(REDIRECT_FIELD_NAME + '=')[1]
|
||||
next_url = unquote(next_value)
|
||||
is_next_ok = next_url == url
|
||||
except:
|
||||
is_next_ok = False
|
||||
self.assertEqual(is_next_ok, True)
|
||||
|
||||
def test_user_consent_inputs(self):
|
||||
"""
|
||||
Once the End-User is authenticated, the Authorization Server MUST
|
||||
|
@ -116,21 +120,18 @@ class AuthorizationCodeFlowTestCase(TestCase):
|
|||
|
||||
See: http://openid.net/specs/openid-connect-core-1_0.html#Consent
|
||||
"""
|
||||
query_str = urlencode({
|
||||
data = {
|
||||
'client_id': self.client.client_id,
|
||||
'response_type': 'code',
|
||||
'redirect_uri': self.client.default_redirect_uri,
|
||||
'scope': 'openid email',
|
||||
'state': self.state,
|
||||
}).replace('+', '%20')
|
||||
# PKCE parameters.
|
||||
'code_challenge': FAKE_CODE_CHALLENGE,
|
||||
'code_challenge_method': 'S256',
|
||||
}
|
||||
|
||||
url = reverse('oidc_provider:authorize') + '?' + query_str
|
||||
|
||||
request = self.factory.get(url)
|
||||
# Simulate that the user is logged.
|
||||
request.user = self.user
|
||||
|
||||
response = AuthorizeView.as_view()(request)
|
||||
response = self._auth_request('get', data, is_user_authenticated=True)
|
||||
|
||||
# Check if hidden inputs exists in the form,
|
||||
# also if their values are valid.
|
||||
|
@ -140,6 +141,8 @@ class AuthorizationCodeFlowTestCase(TestCase):
|
|||
'client_id': self.client.client_id,
|
||||
'redirect_uri': self.client.default_redirect_uri,
|
||||
'response_type': 'code',
|
||||
'code_challenge': FAKE_CODE_CHALLENGE,
|
||||
'code_challenge_method': 'S256',
|
||||
}
|
||||
|
||||
for key, value in iter(to_check.items()):
|
||||
|
@ -159,23 +162,18 @@ class AuthorizationCodeFlowTestCase(TestCase):
|
|||
the parameters defined in Section 4.1.2 of OAuth 2.0 [RFC6749]
|
||||
by adding them as query parameters to the redirect_uri.
|
||||
"""
|
||||
response_type = 'code'
|
||||
|
||||
url = reverse('oidc_provider:authorize')
|
||||
|
||||
post_data = {
|
||||
data = {
|
||||
'client_id': self.client.client_id,
|
||||
'redirect_uri': self.client.default_redirect_uri,
|
||||
'response_type': response_type,
|
||||
'response_type': 'code',
|
||||
'scope': 'openid email',
|
||||
'state': self.state,
|
||||
# PKCE parameters.
|
||||
'code_challenge': FAKE_CODE_CHALLENGE,
|
||||
'code_challenge_method': 'S256',
|
||||
}
|
||||
|
||||
request = self.factory.post(url, data=post_data)
|
||||
# Simulate that the user is logged.
|
||||
request.user = self.user
|
||||
|
||||
response = AuthorizeView.as_view()(request)
|
||||
response = self._auth_request('post', data, is_user_authenticated=True)
|
||||
|
||||
# Because user doesn't allow app, SHOULD exists an error parameter
|
||||
# in the query.
|
||||
|
@ -185,13 +183,9 @@ class AuthorizationCodeFlowTestCase(TestCase):
|
|||
msg='"access_denied" code is missing in query.')
|
||||
|
||||
# Simulate user authorization.
|
||||
post_data['allow'] = 'Accept' # Should be the value of the button.
|
||||
data['allow'] = 'Accept' # Will be the value of the button.
|
||||
|
||||
request = self.factory.post(url, data=post_data)
|
||||
# Simulate that the user is logged.
|
||||
request.user = self.user
|
||||
|
||||
response = AuthorizeView.as_view()(request)
|
||||
response = self._auth_request('post', data, is_user_authenticated=True)
|
||||
|
||||
is_code_ok = is_code_valid(url=response['Location'],
|
||||
user=self.user,
|
||||
|
@ -210,7 +204,7 @@ class AuthorizationCodeFlowTestCase(TestCase):
|
|||
list of scopes) and because they might be prompted for the same
|
||||
authorization multiple times, the server skip it.
|
||||
"""
|
||||
post_data = {
|
||||
data = {
|
||||
'client_id': self.client.client_id,
|
||||
'redirect_uri': self.client.default_redirect_uri,
|
||||
'response_type': 'code',
|
||||
|
@ -220,34 +214,25 @@ class AuthorizationCodeFlowTestCase(TestCase):
|
|||
}
|
||||
|
||||
request = self.factory.post(reverse('oidc_provider:authorize'),
|
||||
data=post_data)
|
||||
data=data)
|
||||
# Simulate that the user is logged.
|
||||
request.user = self.user
|
||||
|
||||
with self.settings(OIDC_SKIP_CONSENT_ALWAYS=True):
|
||||
response = AuthorizeView.as_view()(request)
|
||||
response = self._auth_request('post', data, is_user_authenticated=True)
|
||||
|
||||
self.assertEqual('code' in response['Location'], True,
|
||||
msg='Code is missing in the returned url.')
|
||||
|
||||
response = AuthorizeView.as_view()(request)
|
||||
response = self._auth_request('post', data, is_user_authenticated=True)
|
||||
|
||||
is_code_ok = is_code_valid(url=response['Location'],
|
||||
user=self.user,
|
||||
client=self.client)
|
||||
self.assertEqual(is_code_ok, True, msg='Code returned is invalid.')
|
||||
|
||||
del post_data['allow']
|
||||
query_str = urlencode(post_data).replace('+', '%20')
|
||||
|
||||
url = reverse('oidc_provider:authorize') + '?' + query_str
|
||||
|
||||
request = self.factory.get(url)
|
||||
# Simulate that the user is logged.
|
||||
request.user = self.user
|
||||
|
||||
# Ensure user consent skip is enabled.
|
||||
response = AuthorizeView.as_view()(request)
|
||||
del data['allow']
|
||||
response = self._auth_request('get', data, is_user_authenticated=True)
|
||||
|
||||
is_code_ok = is_code_valid(url=response['Location'],
|
||||
user=self.user,
|
||||
|
@ -255,10 +240,7 @@ class AuthorizationCodeFlowTestCase(TestCase):
|
|||
self.assertEqual(is_code_ok, True, msg='Code returned is invalid or missing.')
|
||||
|
||||
def test_response_uri_is_properly_constructed(self):
|
||||
"""
|
||||
TODO
|
||||
"""
|
||||
post_data = {
|
||||
data = {
|
||||
'client_id': self.client.client_id,
|
||||
'redirect_uri': self.client.default_redirect_uri + "?redirect_state=xyz",
|
||||
'response_type': 'code',
|
||||
|
@ -267,100 +249,85 @@ class AuthorizationCodeFlowTestCase(TestCase):
|
|||
'allow': 'Accept',
|
||||
}
|
||||
|
||||
request = self.factory.post(reverse('oidc_provider:authorize'),
|
||||
data=post_data)
|
||||
# Simulate that the user is logged.
|
||||
request.user = self.user
|
||||
response = self._auth_request('post', data, is_user_authenticated=True)
|
||||
|
||||
response = AuthorizeView.as_view()(request)
|
||||
# TODO
|
||||
|
||||
is_code_ok = is_code_valid(url=response['Location'],
|
||||
user=self.user,
|
||||
client=self.client)
|
||||
self.assertEqual(is_code_ok, True,
|
||||
msg='Code returned is invalid.')
|
||||
|
||||
def test_scope_with_plus(self):
|
||||
def test_public_client_auto_approval(self):
|
||||
"""
|
||||
In query string, scope use `+` instead of the space url-encoded.
|
||||
It's recommended not auto-approving requests for non-confidential clients.
|
||||
"""
|
||||
scope_test = 'openid email profile'
|
||||
|
||||
query_str = urlencode({
|
||||
'client_id': self.client.client_id,
|
||||
data = {
|
||||
'client_id': self.client_public.client_id,
|
||||
'response_type': 'code',
|
||||
'redirect_uri': self.client.default_redirect_uri,
|
||||
'scope': scope_test,
|
||||
'redirect_uri': self.client_public.default_redirect_uri,
|
||||
'scope': 'openid email',
|
||||
'state': self.state,
|
||||
})
|
||||
}
|
||||
|
||||
url = reverse('oidc_provider:authorize') + '?' + query_str
|
||||
with self.settings(OIDC_SKIP_CONSENT_ALWAYS=True):
|
||||
response = self._auth_request('get', data, is_user_authenticated=True)
|
||||
|
||||
request = self.factory.get(url)
|
||||
# Simulate that the user is logged.
|
||||
request.user = self.user
|
||||
self.assertEqual('Request for Permission' in response.content.decode('utf-8'), True)
|
||||
|
||||
response = AuthorizeView.as_view()(request)
|
||||
|
||||
self.assertEqual(scope_test in response.content.decode('utf-8'), True)
|
||||
|
||||
|
||||
class ImplicitFlowTestCase(TestCase):
|
||||
"""
|
||||
Test cases for Authorize Endpoint using Implicit Grant Flow.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.factory = RequestFactory()
|
||||
self.user = create_fake_user()
|
||||
self.client = create_fake_client(response_type='id_token token')
|
||||
self.state = uuid.uuid4().hex
|
||||
self.nonce = uuid.uuid4().hex
|
||||
create_rsakey()
|
||||
|
||||
def test_missing_nonce(self):
|
||||
def test_implicit_missing_nonce(self):
|
||||
"""
|
||||
The `nonce` parameter is REQUIRED if you use the Implicit Flow.
|
||||
"""
|
||||
query_str = urlencode({
|
||||
'client_id': self.client.client_id,
|
||||
'response_type': self.client.response_type,
|
||||
'redirect_uri': self.client.default_redirect_uri,
|
||||
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,
|
||||
}).replace('+', '%20')
|
||||
}
|
||||
|
||||
url = reverse('oidc_provider:authorize') + '?' + query_str
|
||||
|
||||
request = self.factory.get(url)
|
||||
# Simulate that the user is logged.
|
||||
request.user = self.user
|
||||
|
||||
response = AuthorizeView.as_view()(request)
|
||||
response = self._auth_request('get', data, is_user_authenticated=True)
|
||||
|
||||
self.assertEqual('#error=invalid_request' in response['Location'], True)
|
||||
|
||||
def test_access_token_response(self):
|
||||
def test_implicit_access_token_response(self):
|
||||
"""
|
||||
Unlike the Authorization Code flow, in which the client makes
|
||||
separate requests for authorization and for an access token, the client
|
||||
receives the access token as the result of the authorization request.
|
||||
"""
|
||||
post_data = {
|
||||
'client_id': self.client.client_id,
|
||||
'redirect_uri': self.client.default_redirect_uri,
|
||||
'response_type': self.client.response_type,
|
||||
data = {
|
||||
'client_id': self.client_implicit.client_id,
|
||||
'redirect_uri': self.client_implicit.default_redirect_uri,
|
||||
'response_type': self.client_implicit.response_type,
|
||||
'scope': 'openid email',
|
||||
'state': self.state,
|
||||
'nonce': self.nonce,
|
||||
'allow': 'Accept',
|
||||
}
|
||||
|
||||
request = self.factory.post(reverse('oidc_provider:authorize'),
|
||||
data=post_data)
|
||||
# Simulate that the user is logged.
|
||||
request.user = self.user
|
||||
|
||||
response = AuthorizeView.as_view()(request)
|
||||
response = self._auth_request('post', data, is_user_authenticated=True)
|
||||
|
||||
self.assertEqual('access_token' in response['Location'], True)
|
||||
|
||||
|
||||
def test_prompt_parameter(self):
|
||||
"""
|
||||
Specifies whether the Authorization Server prompts the End-User for reauthentication and consent.
|
||||
See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
|
||||
"""
|
||||
data = {
|
||||
'client_id': self.client.client_id,
|
||||
'response_type': self.client.response_type,
|
||||
'redirect_uri': self.client.default_redirect_uri,
|
||||
'scope': 'openid email',
|
||||
'state': self.state,
|
||||
}
|
||||
|
||||
data['prompt'] = 'none'
|
||||
|
||||
response = self._auth_request('get', data)
|
||||
|
||||
# An error is returned if an End-User is not already authenticated.
|
||||
self.assertEqual('login_required' in response['Location'], True)
|
||||
|
||||
response = self._auth_request('get', data, is_user_authenticated=True)
|
||||
|
||||
# An error is returned if the Client does not have pre-configured consent for the requested Claims.
|
||||
self.assertEqual('interaction_required' in response['Location'], True)
|
||||
|
|
|
@ -4,6 +4,7 @@ try:
|
|||
except ImportError:
|
||||
from urllib import urlencode
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.test import RequestFactory, override_settings
|
||||
from django.test import TestCase
|
||||
from jwkest.jwk import KEYS
|
||||
|
@ -23,10 +24,10 @@ class TokenTestCase(TestCase):
|
|||
"""
|
||||
|
||||
def setUp(self):
|
||||
call_command('creatersakey')
|
||||
self.factory = RequestFactory()
|
||||
self.user = create_fake_user()
|
||||
self.client = create_fake_client(response_type='code')
|
||||
create_rsakey()
|
||||
|
||||
def _auth_code_post_data(self, code):
|
||||
"""
|
||||
|
@ -445,3 +446,22 @@ class TokenTestCase(TestCase):
|
|||
|
||||
self.assertEqual(id_token.get('test_idtoken_processing_hook2'), FAKE_RANDOM_STRING)
|
||||
self.assertEqual(id_token.get('test_idtoken_processing_hook_user_email2'), self.user.email)
|
||||
|
||||
def test_pkce_parameters(self):
|
||||
"""
|
||||
Test Proof Key for Code Exchange by OAuth Public Clients.
|
||||
https://tools.ietf.org/html/rfc7636
|
||||
"""
|
||||
code = create_code(user=self.user, client=self.client,
|
||||
scope=['openid', 'email'], nonce=FAKE_NONCE, is_authentication=True,
|
||||
code_challenge=FAKE_CODE_CHALLENGE, code_challenge_method='S256')
|
||||
code.save()
|
||||
|
||||
post_data = self._auth_code_post_data(code=code.code)
|
||||
|
||||
# Add parameters.
|
||||
post_data['code_verifier'] = FAKE_CODE_VERIFIER
|
||||
|
||||
response = self._post_request(post_data)
|
||||
|
||||
response_dic = json.loads(response.content.decode('utf-8'))
|
||||
|
|
|
@ -40,12 +40,14 @@ class AuthorizeView(View):
|
|||
if hook_resp:
|
||||
return hook_resp
|
||||
|
||||
if settings.get('OIDC_SKIP_CONSENT_ALWAYS'):
|
||||
if settings.get('OIDC_SKIP_CONSENT_ALWAYS') and not (authorize.client.client_type == 'public') \
|
||||
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():
|
||||
if authorize.client_has_user_consent() and not (authorize.client.client_type == 'public') \
|
||||
and not (authorize.params.prompt == 'consent'):
|
||||
return redirect(authorize.create_response_uri())
|
||||
|
||||
# Generate hidden inputs for the form.
|
||||
|
@ -66,10 +68,22 @@ class AuthorizeView(View):
|
|||
'params': authorize.params,
|
||||
}
|
||||
|
||||
if authorize.params.prompt == 'none':
|
||||
raise AuthorizeError(authorize.params.redirect_uri, 'interaction_required', authorize.grant_type)
|
||||
|
||||
if authorize.params.prompt == 'login':
|
||||
return redirect_to_login(request.get_full_path())
|
||||
|
||||
if authorize.params.prompt == 'select_account':
|
||||
# TODO: see how we can support multiple accounts for the end-user.
|
||||
raise AuthorizeError(authorize.params.redirect_uri, 'account_selection_required', authorize.grant_type)
|
||||
|
||||
return render(request, 'oidc_provider/authorize.html', context)
|
||||
else:
|
||||
path = request.get_full_path()
|
||||
return redirect_to_login(path)
|
||||
if authorize.params.prompt == 'none':
|
||||
raise AuthorizeError(authorize.params.redirect_uri, 'login_required', authorize.grant_type)
|
||||
|
||||
return redirect_to_login(request.get_full_path())
|
||||
|
||||
except (ClientIdError, RedirectUriError) as error:
|
||||
context = {
|
||||
|
@ -87,15 +101,12 @@ class AuthorizeView(View):
|
|||
return redirect(uri)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
authorize = AuthorizeEndpoint(request)
|
||||
|
||||
allow = True if request.POST.get('allow') else False
|
||||
|
||||
try:
|
||||
authorize.validate_params()
|
||||
|
||||
if not allow:
|
||||
if not request.POST.get('allow'):
|
||||
raise AuthorizeError(authorize.params.redirect_uri,
|
||||
'access_denied',
|
||||
authorize.grant_type)
|
||||
|
|
2
tox.ini
2
tox.ini
|
@ -1,7 +1,7 @@
|
|||
[tox]
|
||||
|
||||
envlist=
|
||||
clean,py{27,34}-django{17,18,19},stats
|
||||
clean,py{27,34}-django{17,18,19},py35-django{18,19},stats
|
||||
|
||||
[testenv]
|
||||
|
||||
|
|
Loading…
Reference in a new issue