diff --git a/.travis.yml b/.travis.yml index 56c5e66..d947e99 100644 --- a/.travis.yml +++ b/.travis.yml @@ -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 . diff --git a/CHANGELOG.md b/CHANGELOG.md index d2c88b7..811ee36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/conf.py b/docs/conf.py index cf7b163..bb4f14f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -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. diff --git a/docs/images/client_creation.png b/docs/images/client_creation.png new file mode 100644 index 0000000..fac2105 Binary files /dev/null and b/docs/images/client_creation.png differ diff --git a/docs/index.rst b/docs/index.rst index 466355f..f33b663 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -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 `_ +* `Proof Key for Code Exchange by OAuth Public Clients `_ + -------------------------------------------------------------------------------- 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 diff --git a/docs/sections/clients.rst b/docs/sections/clients.rst deleted file mode 100644 index e3f5ab8..0000000 --- a/docs/sections/clients.rst +++ /dev/null @@ -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 `_ - -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() diff --git a/docs/sections/relyingparties.rst b/docs/sections/relyingparties.rst new file mode 100644 index 0000000..1c6d80c --- /dev/null +++ b/docs/sections/relyingparties.rst @@ -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 `_ diff --git a/example_project/.gitignore b/example_project/.gitignore index 6ddd3e2..9dd04fe 100644 --- a/example_project/.gitignore +++ b/example_project/.gitignore @@ -1,3 +1,3 @@ *.sqlite3 *.pem - +static/ diff --git a/example_project/provider_app/urls.py b/example_project/provider_app/urls.py index 1757fa8..12e6abf 100644 --- a/example_project/provider_app/urls.py +++ b/example_project/provider_app/urls.py @@ -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)), ] diff --git a/oidc_provider/admin.py b/oidc_provider/admin.py index 9963b35..2a4fc61 100644 --- a/oidc_provider/admin.py +++ b/oidc_provider/admin.py @@ -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) diff --git a/oidc_provider/lib/endpoints/authorize.py b/oidc_provider/lib/endpoints/authorize.py index 56972a4..dcdb8da 100644 --- a/oidc_provider/lib/endpoints/authorize.py +++ b/oidc_provider/lib/endpoints/authorize.py @@ -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() diff --git a/oidc_provider/lib/endpoints/token.py b/oidc_provider/lib/endpoints/token.py index a981eee..2a687f5 100644 --- a/oidc_provider/lib/endpoints/token.py +++ b/oidc_provider/lib/endpoints/token.py @@ -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') diff --git a/oidc_provider/lib/utils/token.py b/oidc_provider/lib/utils/token.py index e512326..8317ecf 100644 --- a/oidc_provider/lib/utils/token.py +++ b/oidc_provider/lib/utils/token.py @@ -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 diff --git a/oidc_provider/migrations/0011_client_client_type.py b/oidc_provider/migrations/0011_client_client_type.py new file mode 100644 index 0000000..26e9fc3 --- /dev/null +++ b/oidc_provider/migrations/0011_client_client_type.py @@ -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='Confidential clients are capable of maintaining the confidentiality of their credentials. Public clients are incapable.', max_length=30), + ), + ] diff --git a/oidc_provider/migrations/0012_auto_20160405_2041.py b/oidc_provider/migrations/0012_auto_20160405_2041.py new file mode 100644 index 0000000..c04b613 --- /dev/null +++ b/oidc_provider/migrations/0012_auto_20160405_2041.py @@ -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), + ), + ] diff --git a/oidc_provider/migrations/0013_auto_20160407_1912.py b/oidc_provider/migrations/0013_auto_20160407_1912.py new file mode 100644 index 0000000..19cb444 --- /dev/null +++ b/oidc_provider/migrations/0013_auto_20160407_1912.py @@ -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), + ), + ] diff --git a/oidc_provider/models.py b/oidc_provider/models.py index 68ace73..2944231 100644 --- a/oidc_provider/models.py +++ b/oidc_provider/models.py @@ -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'Confidential clients are capable of maintaining the confidentiality of their credentials. Public 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') diff --git a/oidc_provider/templates/oidc_provider/hidden_inputs.html b/oidc_provider/templates/oidc_provider/hidden_inputs.html index 59c7035..2bff39d 100644 --- a/oidc_provider/templates/oidc_provider/hidden_inputs.html +++ b/oidc_provider/templates/oidc_provider/hidden_inputs.html @@ -3,4 +3,6 @@ - \ No newline at end of file + + + diff --git a/oidc_provider/tests/app/RSAKEY.pem b/oidc_provider/tests/app/RSAKEY.pem deleted file mode 100644 index bcad3a0..0000000 --- a/oidc_provider/tests/app/RSAKEY.pem +++ /dev/null @@ -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----- \ No newline at end of file diff --git a/oidc_provider/tests/app/utils.py b/oidc_provider/tests/app/utils.py index bd3989d..684dac8 100644 --- a/oidc_provider/tests/app/utils.py +++ b/oidc_provider/tests/app/utils.py @@ -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. diff --git a/oidc_provider/tests/test_authorize_endpoint.py b/oidc_provider/tests/test_authorize_endpoint.py index fc92fcc..dd22800 100644 --- a/oidc_provider/tests/test_authorize_endpoint.py +++ b/oidc_provider/tests/test_authorize_endpoint.py @@ -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) diff --git a/oidc_provider/tests/test_token_endpoint.py b/oidc_provider/tests/test_token_endpoint.py index b17408d..0873b23 100644 --- a/oidc_provider/tests/test_token_endpoint.py +++ b/oidc_provider/tests/test_token_endpoint.py @@ -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')) diff --git a/oidc_provider/views.py b/oidc_provider/views.py index 01f5d1b..9ed42c9 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -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) diff --git a/tox.ini b/tox.ini index 92540ff..2b107d0 100644 --- a/tox.ini +++ b/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]