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:
Ignacio 2016-04-16 18:02:26 -03:00
commit 7f5f1eb584
24 changed files with 379 additions and 239 deletions

View file

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View file

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

View file

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

View 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>`_

View file

@ -1,3 +1,3 @@
*.sqlite3
*.pem
static/

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

View file

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

View file

@ -3,4 +3,6 @@
<input name="response_type" type="hidden" value="{{ params.response_type }}" />
<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="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 }}" />

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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