diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..662af4f4 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,26 @@ +name: Django Tests CI + +on: + push: + branches: ["master", "develop"] + pull_request: + branches: ["develop"] + +jobs: + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: | + 3.8 + 3.9 + 3.10 + 3.11 + - name: Install tox + run: | + python -m pip install --upgrade pip + pip install tox + - name: Run tox + run: tox diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 5fa9ac70..00000000 --- a/.travis.yml +++ /dev/null @@ -1,22 +0,0 @@ -language: python - -install: - - pip install tox coveralls -matrix: - include: - - python: 2.7 - env: - - ENV=docs - - python: 2.7 - env: - - ENV=py27-django111 - - python: 3.5 - env: - - ENV=py35-django111,py35-django20,py35-django21 - - python: 3.6 - env: - - ENV=py36-django111,py36-django20,py36-django21 -script: - - tox -e $ENV -after_success: - - coveralls diff --git a/README.md b/README.md index 074dc01a..86847553 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # Django OpenID Connect Provider [![Python Versions](https://img.shields.io/pypi/pyversions/django-oidc-provider.svg)](https://pypi.python.org/pypi/django-oidc-provider) +[![Django Versions](https://img.shields.io/badge/Django-3.2%20%7C%204.2-green)](https://pypi.python.org/pypi/django-oidc-provider) [![PyPI Versions](https://img.shields.io/pypi/v/django-oidc-provider.svg)](https://pypi.python.org/pypi/django-oidc-provider) [![Documentation Status](https://readthedocs.org/projects/django-oidc-provider/badge/?version=master)](http://django-oidc-provider.readthedocs.io/) -[![Travis](https://travis-ci.org/juanifioren/django-oidc-provider.svg?branch=master)](https://travis-ci.org/juanifioren/django-oidc-provider) ## About OpenID diff --git a/docs/conf.py b/docs/conf.py index 62ca4c11..f4fe2ecb 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -45,7 +45,7 @@ # General information about the project. project = u'django-oidc-provider' -copyright = u'2016, Juan Ignacio Fiorentino' +copyright = u'2023, Juan Ignacio Fiorentino' author = u'Juan Ignacio Fiorentino' # The version info for the project you're documenting, acts as replacement for @@ -53,16 +53,16 @@ # built documents. # # The short X.Y version. -version = u'0.5' +version = u'0.8' # The full version, including alpha/beta/rc tags. -release = u'0.5.x' +release = u'0.8.0' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = 'en' # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: diff --git a/docs/sections/changelog.rst b/docs/sections/changelog.rst index 9508aa20..378715a6 100644 --- a/docs/sections/changelog.rst +++ b/docs/sections/changelog.rst @@ -8,7 +8,18 @@ All notable changes to this project will be documented in this file. Unreleased ========== -* Fixed: example project on Django 2.1. +* Changed: create_token and create_code are now methods on base classes to enable customization. +* Changed: extract "is consent skip allowed" decision from the view to the endpoint. + +0.8.0 +===== + +*2023-05-05* + +* Changed: now supporting latest versions of Django. +* Changed: drop support for Python 2 and Django lower than 3.2. +* Added: scope on token and introspection endpoints. +* Changed: Use static instead of deprecated staticfiles template tag. * Fixed: example in docs for translatable scopes (ugettext). 0.7.0 diff --git a/docs/sections/contribute.rst b/docs/sections/contribute.rst index e67769c2..b9bf830d 100644 --- a/docs/sections/contribute.rst +++ b/docs/sections/contribute.rst @@ -7,10 +7,10 @@ We love contributions, so please feel free to fix bugs, improve things, provide * Create an issue and explain your feature/bugfix. * Wait collaborators comments. -* Fork the project and create new branch from `develop`. +* Fork the project and create new branch from ``develop``. * Make your feature addition or bug fix. * Add tests and documentation if needed. -* Create pull request for the issue to the `develop` branch. +* Create pull request for the issue to the ``develop`` branch. * Wait collaborators reviews. Running Tests @@ -21,18 +21,18 @@ Use `tox `_ for running tests in each of the e # Run all tests. $ tox - # Run with Python 3.5 and Django 2.0. - $ tox -e py35-django20 + # Run with Python 3.11 and Django 4.2. + $ tox -e py311-django42 # Run single test file on specific environment. - $ tox -e py35-django20 tests/cases/test_authorize_endpoint.py + $ tox -e py311-django42 tests/cases/test_authorize_endpoint.py -We also use `travis `_ to automatically test every commit to the project. +We use `Github Actions `_ to automatically test every commit to the project. Improve Documentation ===================== -We use `Sphinx `_ for generate this documentation. I you want to add or modify something just: +We use `Sphinx `_ to generate this documentation. If you want to add or modify something just: * Install Sphinx (``pip install sphinx``) and the auto-build tool (``pip install sphinx-autobuild``). * Move inside the docs folder. ``cd docs/`` diff --git a/docs/sections/installation.rst b/docs/sections/installation.rst index 3a926266..45f23457 100644 --- a/docs/sections/installation.rst +++ b/docs/sections/installation.rst @@ -6,8 +6,8 @@ Installation Requirements ============ -* Python: ``2.7`` ``3.4`` ``3.5`` ``3.6`` -* Django: ``1.8`` ``1.9`` ``1.10`` ``1.11`` ``2.0`` +* Python: ``3.8`` ``3.9`` ``3.10`` ``3.11`` +* Django: ``3.2`` ``4.2`` Quick Installation ================== @@ -20,24 +20,19 @@ Install the package using pip:: Add it to your apps in your project's django settings:: - INSTALLED_APPS = ( - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', + INSTALLED_APPS = [ + # ... 'oidc_provider', # ... - ) + ] Include our urls to your project's ``urls.py``:: - urlpatterns = patterns('', + urlpatterns = [ # ... - url(r'^openid/', include('oidc_provider.urls', namespace='oidc_provider')), + path('openid/', include('oidc_provider.urls', namespace='oidc_provider')), # ... - ) + ] Run the migrations and generate a server RSA key:: diff --git a/docs/sections/relyingparties.rst b/docs/sections/relyingparties.rst index 3a3028ce..b31b2822 100644 --- a/docs/sections/relyingparties.rst +++ b/docs/sections/relyingparties.rst @@ -19,7 +19,7 @@ Properties * ``client_type``: Values are ``confidential`` and ``public``. * ``client_id``: Client unique identifier. * ``client_secret``: Client secret for confidential applications. -* ``response_types``: The flows and associated ```response_type``` values that can be used by the client. +* ``response_types``: The flows and associated ``response_type`` values that can be used by the client. * ``jwt_alg``: Clients can choose which algorithm will be used to sign id_tokens. Values are ``HS256`` and ``RS256``. * ``date_created``: Date automatically added when created. * ``redirect_uris``: List of redirect URIs. diff --git a/docs/sections/settings.rst b/docs/sections/settings.rst index eae2e8b7..984d2df2 100644 --- a/docs/sections/settings.rst +++ b/docs/sections/settings.rst @@ -234,3 +234,14 @@ Default is:: See the :ref:`templates` section. The templates that are not specified here will use the default ones. + +OIDC_INTROSPECTION_RESPONSE_SCOPE_ENABLE +========================================== + +OPTIONAL ``bool`` + +A flag which toggles whether the scope is returned with successful response on introspection request. + +Must be ``True`` to include ``scope`` into the successful response + +Default is ``False``. \ No newline at end of file diff --git a/oidc_provider/admin.py b/oidc_provider/admin.py index 8525de82..86a90fc7 100644 --- a/oidc_provider/admin.py +++ b/oidc_provider/admin.py @@ -4,7 +4,7 @@ from django.forms import ModelForm from django.contrib import admin -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from oidc_provider.models import Client, Code, Token, RSAKey @@ -75,6 +75,8 @@ class ClientAdmin(admin.ModelAdmin): @admin.register(Code) class CodeAdmin(admin.ModelAdmin): + raw_id_fields = ['user'] + def has_add_permission(self, request): return False @@ -82,6 +84,8 @@ def has_add_permission(self, request): @admin.register(Token) class TokenAdmin(admin.ModelAdmin): + raw_id_fields = ['user'] + def has_add_permission(self, request): return False diff --git a/oidc_provider/lib/claims.py b/oidc_provider/lib/claims.py index a1dc1c1a..c641e5b5 100644 --- a/oidc_provider/lib/claims.py +++ b/oidc_provider/lib/claims.py @@ -1,6 +1,6 @@ import copy -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from oidc_provider import settings diff --git a/oidc_provider/lib/endpoints/authorize.py b/oidc_provider/lib/endpoints/authorize.py index 51a75c6f..4728158e 100644 --- a/oidc_provider/lib/endpoints/authorize.py +++ b/oidc_provider/lib/endpoints/authorize.py @@ -126,6 +126,28 @@ def validate_params(self): raise AuthorizeError( self.params['redirect_uri'], 'invalid_request', self.grant_type) + def create_code(self): + code = create_code( + user=self.request.user, + client=self.client, + scope=self.params['scope'], + nonce=self.params['nonce'], + is_authentication=self.is_authentication, + code_challenge=self.params['code_challenge'], + code_challenge_method=self.params['code_challenge_method'], + ) + + return code + + def create_token(self): + token = create_token( + user=self.request.user, + client=self.client, + scope=self.params['scope'], + ) + + return token + def create_response_uri(self): uri = urlsplit(self.params['redirect_uri']) query_params = parse_qs(uri.query) @@ -133,24 +155,13 @@ def create_response_uri(self): try: if self.grant_type in ['authorization_code', 'hybrid']: - code = create_code( - user=self.request.user, - client=self.client, - scope=self.params['scope'], - nonce=self.params['nonce'], - is_authentication=self.is_authentication, - code_challenge=self.params['code_challenge'], - code_challenge_method=self.params['code_challenge_method']) + code = self.create_code() code.save() - if self.grant_type == 'authorization_code': query_params['code'] = code.code query_params['state'] = self.params['state'] if self.params['state'] else '' elif self.grant_type in ['implicit', 'hybrid']: - token = create_token( - user=self.request.user, - client=self.client, - scope=self.params['scope']) + token = self.create_token() # Check if response_type must include access_token in the response. if (self.params['response_type'] in @@ -270,6 +281,13 @@ def client_has_user_consent(self): return value + def is_client_allowed_to_skip_consent(self): + implicit_flow_resp_types = {'id_token', 'id_token token'} + return ( + self.client.client_type != 'public' or + self.params['response_type'] in implicit_flow_resp_types + ) + def get_scopes_information(self): """ Return a list with the description of all the scopes requested. diff --git a/oidc_provider/lib/endpoints/introspection.py b/oidc_provider/lib/endpoints/introspection.py index 8f41de93..c1e8a8e6 100644 --- a/oidc_provider/lib/endpoints/introspection.py +++ b/oidc_provider/lib/endpoints/introspection.py @@ -85,7 +85,8 @@ def create_response_dic(self): response_dic[k] = self.id_token[k] response_dic['active'] = True response_dic['client_id'] = self.token.client.client_id - + if settings.get('OIDC_INTROSPECTION_RESPONSE_SCOPE_ENABLE'): + response_dic['scope'] = ' '.join(self.token.scope) response_dic = run_processing_hook(response_dic, 'OIDC_INTROSPECTION_PROCESSING_HOOK', client=self.client, diff --git a/oidc_provider/lib/endpoints/token.py b/oidc_provider/lib/endpoints/token.py index 8c320462..991a6675 100644 --- a/oidc_provider/lib/endpoints/token.py +++ b/oidc_provider/lib/endpoints/token.py @@ -1,11 +1,12 @@ -import inspect -from base64 import urlsafe_b64encode import hashlib +import inspect import logging -from django.contrib.auth import authenticate +from base64 import urlsafe_b64encode +from django.contrib.auth import authenticate from django.http import JsonResponse +from oidc_provider import settings from oidc_provider.lib.errors import ( TokenError, UserAuthError, @@ -21,7 +22,6 @@ Code, Token, ) -from oidc_provider import settings logger = logging.getLogger(__name__) @@ -76,7 +76,7 @@ def validate_params(self): raise TokenError('invalid_grant') if not (self.code.client == self.client) \ - or self.code.has_expired(): + or self.code.has_expired(): logger.debug('[Token] Invalid code: invalid client or code has expired') raise TokenError('invalid_grant') @@ -84,8 +84,8 @@ def validate_params(self): 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('=', '') + hashlib.sha256(self.params['code_verifier'].encode('ascii')).digest() + ).decode('utf-8').replace('=', '') else: new_code_challenge = self.params['code_verifier'] @@ -135,6 +135,27 @@ def validate_params(self): logger.debug('[Token] Invalid grant type: %s', self.params['grant_type']) raise TokenError('unsupported_grant_type') + def validate_requested_scopes(self): + """ + Handling validation of requested scope for grant_type=[password|client_credentials] + """ + token_scopes = [] + if self.params['scope']: + # See https://tools.ietf.org/html/rfc6749#section-3.3 + # The value of the scope parameter is expressed + # as a list of space-delimited, case-sensitive strings + for scope_requested in self.params['scope'].split(' '): + if scope_requested in self.client.scope: + token_scopes.append(scope_requested) + else: + logger.debug('[Token] The request scope %s is not supported by client %s', + scope_requested, self.client.client_id) + raise TokenError('invalid_scope') + # if no scopes requested assign client's scopes + else: + token_scopes.extend(self.client.scope) + return token_scopes + def create_response_dic(self): if self.params['grant_type'] == 'authorization_code': return self.create_code_response_dic() @@ -145,13 +166,23 @@ def create_response_dic(self): elif self.params['grant_type'] == 'client_credentials': return self.create_client_credentials_response_dic() + def create_token(self, user, client, scope): + token = create_token( + user=user, + client=client, + scope=scope, + ) + + return token + def create_code_response_dic(self): # See https://tools.ietf.org/html/rfc6749#section-4.1 - token = create_token( + token = self.create_token( user=self.code.user, client=self.code.client, - scope=self.code.scope) + scope=self.code.scope, + ) if self.code.is_authentication: id_token_dic = create_id_token( @@ -192,10 +223,11 @@ def create_refresh_response_dic(self): if unauthorized_scopes: raise TokenError('invalid_scope') - token = create_token( + token = self.create_token( user=self.token.user, client=self.token.client, - scope=scope) + scope=scope, + ) # If the Token has an id_token it's an Authentication request. if self.token.id_token: @@ -230,11 +262,12 @@ def create_refresh_response_dic(self): def create_access_token_response_dic(self): # See https://tools.ietf.org/html/rfc6749#section-4.3 - - token = create_token( + token_scopes = self.validate_requested_scopes() + token = self.create_token( self.user, self.client, - self.params['scope'].split(' ')) + token_scopes, + ) id_token_dic = create_id_token( token=token, @@ -255,23 +288,25 @@ def create_access_token_response_dic(self): 'expires_in': settings.get('OIDC_TOKEN_EXPIRE'), 'token_type': 'bearer', 'id_token': encode_id_token(id_token_dic, token.client), + 'scope': ' '.join(token.scope) } def create_client_credentials_response_dic(self): # See https://tools.ietf.org/html/rfc6749#section-4.4.3 + token_scopes = self.validate_requested_scopes() - token = create_token( + token = self.create_token( user=None, client=self.client, - scope=self.client.scope) - + scope=token_scopes, + ) token.save() return { 'access_token': token.access_token, 'expires_in': settings.get('OIDC_TOKEN_EXPIRE'), 'token_type': 'bearer', - 'scope': self.client._scope, + 'scope': ' '.join(token.scope), } @classmethod diff --git a/oidc_provider/lib/utils/token.py b/oidc_provider/lib/utils/token.py index 23012231..d3fd3ab2 100644 --- a/oidc_provider/lib/utils/token.py +++ b/oidc_provider/lib/utils/token.py @@ -55,11 +55,12 @@ def create_id_token(token, user, aud, nonce='', at_hash='', request=None, scope= # Inlude (or not) user standard claims in the id_token. if settings.get('OIDC_IDTOKEN_INCLUDE_CLAIMS'): - standard_claims = StandardScopeClaims(token) - dic.update(standard_claims.create_response_dic()) if settings.get('OIDC_EXTRA_SCOPE_CLAIMS'): custom_claims = settings.get('OIDC_EXTRA_SCOPE_CLAIMS', import_str=True)(token) - dic.update(custom_claims.create_response_dic()) + claims = custom_claims.create_response_dic() + else: + claims = StandardScopeClaims(token).create_response_dic() + dic.update(claims) dic = run_processing_hook( dic, 'OIDC_IDTOKEN_PROCESSING_HOOK', diff --git a/oidc_provider/models.py b/oidc_provider/models.py index 65042389..45d66dc2 100644 --- a/oidc_provider/models.py +++ b/oidc_provider/models.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import base64 import binascii from hashlib import md5, sha256 @@ -6,7 +5,7 @@ from django.db import models from django.utils import timezone -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from django.conf import settings diff --git a/oidc_provider/settings.py b/oidc_provider/settings.py index 6d0607ee..90750fda 100644 --- a/oidc_provider/settings.py +++ b/oidc_provider/settings.py @@ -168,6 +168,13 @@ def OIDC_TEMPLATES(self): 'error': 'oidc_provider/error.html' } + @property + def OIDC_INTROSPECTION_RESPONSE_SCOPE_ENABLE(self): + """ + OPTIONAL: A boolean to specify whether or not to include scope in introspection response. + """ + return False + default_settings = DefaultSettings() diff --git a/oidc_provider/signals.py b/oidc_provider/signals.py index 679417cc..ba3d5e50 100644 --- a/oidc_provider/signals.py +++ b/oidc_provider/signals.py @@ -2,5 +2,5 @@ from django.dispatch import Signal -user_accept_consent = Signal(providing_args=['user', 'client', 'scope']) -user_decline_consent = Signal(providing_args=['user', 'client', 'scope']) +user_accept_consent = Signal() +user_decline_consent = Signal() diff --git a/oidc_provider/templates/oidc_provider/check_session_iframe.html b/oidc_provider/templates/oidc_provider/check_session_iframe.html index e04d5ce1..a0bed2f5 100644 --- a/oidc_provider/templates/oidc_provider/check_session_iframe.html +++ b/oidc_provider/templates/oidc_provider/check_session_iframe.html @@ -1,4 +1,4 @@ -{% load staticfiles %} +{% load static %} diff --git a/oidc_provider/tests/app/urls.py b/oidc_provider/tests/app/urls.py index cbaadf59..a7f8c943 100644 --- a/oidc_provider/tests/app/urls.py +++ b/oidc_provider/tests/app/urls.py @@ -1,18 +1,22 @@ from django.contrib.auth import views as auth_views + try: - from django.urls import include, url + from django.urls import include, re_path except ImportError: - from django.conf.urls import include, url + from django.conf.urls import include + from django.conf.urls import url as re_path from django.contrib import admin from django.views.generic import TemplateView urlpatterns = [ - url(r'^$', TemplateView.as_view(template_name='home.html'), name='home'), - url(r'^accounts/login/$', - auth_views.LoginView.as_view(template_name='accounts/login.html'), name='login'), - url(r'^accounts/logout/$', - auth_views.LogoutView.as_view(template_name='accounts/logout.html'), name='logout'), - url(r'^openid/', include('oidc_provider.urls', namespace='oidc_provider')), - url(r'^admin/', admin.site.urls), + re_path(r'^$', TemplateView.as_view(template_name='home.html'), name='home'), + re_path(r'^accounts/login/$', + auth_views.LoginView.as_view(template_name='accounts/login.html'), + name='login'), + re_path(r'^accounts/logout/$', + auth_views.LogoutView.as_view(template_name='accounts/logout.html'), + name='logout'), + re_path(r'^openid/', include('oidc_provider.urls', namespace='oidc_provider')), + re_path(r'^admin/', admin.site.urls), ] diff --git a/oidc_provider/tests/app/utils.py b/oidc_provider/tests/app/utils.py index 6a65b64e..51f51d4e 100644 --- a/oidc_provider/tests/app/utils.py +++ b/oidc_provider/tests/app/utils.py @@ -61,7 +61,7 @@ def create_fake_client(response_type, is_public=False, require_consent=True): client.client_secret = str(random.randint(1, 999999)).zfill(6) client.redirect_uris = ['http://example.com/'] client.require_consent = require_consent - + client.scope = ['openid', 'email'] client.save() # check if response_type is a string in a python 2 and 3 compatible way diff --git a/oidc_provider/tests/cases/test_authorize_endpoint.py b/oidc_provider/tests/cases/test_authorize_endpoint.py index 7bbd390f..508b3e70 100644 --- a/oidc_provider/tests/cases/test_authorize_endpoint.py +++ b/oidc_provider/tests/cases/test_authorize_endpoint.py @@ -276,14 +276,14 @@ def test_response_uri_is_properly_constructed(self): parsed = urlsplit(response['Location']) params = parse_qs(parsed.query or parsed.fragment) state = params['state'][0] - self.assertEquals(self.state, state, msg="State returned is invalid or missing") + self.assertEqual(self.state, state, msg="State returned is invalid or missing") is_code_ok = is_code_valid(url=response['Location'], user=self.user, client=self.client) self.assertTrue(is_code_ok, msg='Code returned is invalid or missing') - self.assertEquals( + self.assertEqual( set(params.keys()), {'state', 'code'}, msg='More than state or code appended as query params') @@ -395,7 +395,7 @@ def test_prompt_login_parameter(self, logout_function): response = self._auth_request('get', data, is_user_authenticated=True) self.assertIn(settings.get('OIDC_LOGIN_URL'), response['Location']) - self.assertTrue(logout_function.called_once()) + logout_function.assert_called_once() self.assertNotIn( quote('prompt=login'), response['Location'], @@ -662,7 +662,7 @@ def test_public_client_implicit_auto_approval(self): response = self._auth_request('get', data, is_user_authenticated=True) response_text = response.content.decode('utf-8') - self.assertEquals(response_text, '') + self.assertEqual(response_text, '') components = urlsplit(response['Location']) fragment = parse_qs(components[4]) self.assertIn('access_token', fragment) diff --git a/oidc_provider/tests/cases/test_claims.py b/oidc_provider/tests/cases/test_claims.py index 4c274dcc..1610f773 100644 --- a/oidc_provider/tests/cases/test_claims.py +++ b/oidc_provider/tests/cases/test_claims.py @@ -1,8 +1,9 @@ from __future__ import unicode_literals from django.test import TestCase -from django.utils.six import text_type + from django.utils.translation import override as override_language +from six import text_type from oidc_provider.lib.claims import ScopeClaims, StandardScopeClaims, STANDARD_CLAIMS from oidc_provider.tests.app.utils import create_fake_user, create_fake_client, create_fake_token @@ -49,7 +50,7 @@ def test_clean_dic(self): 'phone_number': '', } clean_dict = self.scopeClaims._clean_dic(dict_to_clean) - self.assertEquals( + self.assertEqual( clean_dict, { 'family_name': 'Doe', diff --git a/oidc_provider/tests/cases/test_commands.py b/oidc_provider/tests/cases/test_commands.py index cb070ec1..2f9248fe 100644 --- a/oidc_provider/tests/cases/test_commands.py +++ b/oidc_provider/tests/cases/test_commands.py @@ -1,6 +1,7 @@ +from io import StringIO + from django.core.management import call_command from django.test import TestCase -from django.utils.six import StringIO class CommandsTest(TestCase): diff --git a/oidc_provider/tests/cases/test_introspection_endpoint.py b/oidc_provider/tests/cases/test_introspection_endpoint.py index 38b3c3c0..34a8ac73 100644 --- a/oidc_provider/tests/cases/test_introspection_endpoint.py +++ b/oidc_provider/tests/cases/test_introspection_endpoint.py @@ -6,7 +6,7 @@ from urllib.parse import urlencode except ImportError: from urllib import urlencode -from django.utils.encoding import force_text +from django.utils.encoding import force_str from django.core.management import call_command from django.test import TestCase, RequestFactory, override_settings from django.utils import timezone @@ -45,7 +45,7 @@ def setUp(self): def _assert_inactive(self, response): self.assertEqual(response.status_code, 200) - self.assertJSONEqual(force_text(response.content), {'active': False}) + self.assertJSONEqual(force_str(response.content), {'active': False}) def _assert_active(self, response, **kwargs): self.assertEqual(response.status_code, 200) @@ -59,7 +59,7 @@ def _assert_active(self, response, **kwargs): 'iss': 'http://localhost:8000/openid', } expected_content.update(kwargs) - self.assertJSONEqual(force_text(response.content), expected_content) + self.assertJSONEqual(force_str(response.content), expected_content) def _make_request(self, **kwargs): url = reverse('oidc_provider:token-introspection') @@ -126,7 +126,12 @@ def test_valid_client_grant_token_without_aud_validation(self): self.resource.save() response = self._make_request() self.assertEqual(response.status_code, 200) - self.assertJSONEqual(force_text(response.content), { + self.assertJSONEqual(force_str(response.content), { 'active': True, 'client_id': self.client.client_id, }) + + @override_settings(OIDC_INTROSPECTION_RESPONSE_SCOPE_ENABLE=True) + def test_enable_scope(self): + response = self._make_request() + self._assert_active(response, scope='openid email') diff --git a/oidc_provider/tests/cases/test_middleware.py b/oidc_provider/tests/cases/test_middleware.py index 4c93b0c5..17339285 100644 --- a/oidc_provider/tests/cases/test_middleware.py +++ b/oidc_provider/tests/cases/test_middleware.py @@ -1,17 +1,15 @@ -try: - from django.urls import url -except ImportError: - from django.conf.urls import url +import mock + +from django.urls import re_path from django.test import TestCase, override_settings from django.views.generic import View -from mock import mock class StubbedViews: class SampleView(View): pass - urlpatterns = [url('^test/', SampleView.as_view())] + urlpatterns = [re_path('^test/', SampleView.as_view())] MW_CLASSES = ('django.contrib.sessions.middleware.SessionMiddleware', diff --git a/oidc_provider/tests/cases/test_settings.py b/oidc_provider/tests/cases/test_settings.py index 00510bff..1a8a0f7b 100644 --- a/oidc_provider/tests/cases/test_settings.py +++ b/oidc_provider/tests/cases/test_settings.py @@ -16,7 +16,7 @@ def test_override_templates(self): def test_unauthenticated_session_management_key_has_default(self): key = settings.get('OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY') - self.assertRegexpMatches(key, r'[a-zA-Z0-9]+') + self.assertRegex(key, r'[a-zA-Z0-9]+') self.assertGreater(len(key), 50) def test_unauthenticated_session_management_key_has_constant_value(self): diff --git a/oidc_provider/tests/cases/test_token_endpoint.py b/oidc_provider/tests/cases/test_token_endpoint.py index dab90e76..91f950d3 100644 --- a/oidc_provider/tests/cases/test_token_endpoint.py +++ b/oidc_provider/tests/cases/test_token_endpoint.py @@ -11,6 +11,7 @@ from django.core.management import call_command from django.http import JsonResponse + try: from django.urls import reverse except ImportError: @@ -51,6 +52,8 @@ class TokenTestCase(TestCase): Token Request to the Token Endpoint to obtain a Token Response when using the Authorization Code Flow. """ + SCOPE = 'openid email' + SCOPE_LIST = SCOPE.split(' ') def setUp(self): call_command('creatersakey') @@ -64,7 +67,7 @@ def _password_grant_post_data(self, scope=None): 'username': 'johndoe', 'password': '1234', 'grant_type': 'password', - 'scope': 'openid email', + 'scope': TokenTestCase.SCOPE, } if scope is not None: result['scope'] = ' '.join(scope) @@ -102,6 +105,16 @@ def _refresh_token_post_data(self, refresh_token, scope=None): return post_data + def _client_credentials_post_data(self, scope=None): + post_data = { + 'client_id': self.client.client_id, + 'client_secret': self.client.client_secret, + 'grant_type': 'client_credentials', + } + if scope is not None: + post_data['scope'] = ' '.join(scope) + return post_data + def _post_request(self, post_data, extras={}): """ Makes a request to the token endpoint by sending the @@ -127,7 +140,7 @@ def _create_code(self, scope=None): code = create_code( user=self.user, client=self.client, - scope=(scope if scope else ['openid', 'email']), + scope=(scope if scope else TokenTestCase.SCOPE_LIST), nonce=FAKE_NONCE, is_authentication=True) code.save() @@ -227,7 +240,11 @@ def test_password_grant_full_response(self): self.check_password_grant(scope=['openid', 'email']) def test_password_grant_scope(self): - self.check_password_grant(scope=['openid', 'profile']) + scopes_list = ['openid', 'profile'] + + self.client.scope = scopes_list + self.client.save() + self.check_password_grant(scope=scopes_list) @override_settings(OIDC_TOKEN_EXPIRE=120, OIDC_GRANT_TYPE_PASSWORD_ENABLE=True) @@ -361,7 +378,7 @@ def do_refresh_token_check(self, scope=None): # Retrieve refresh token code = self._create_code() - self.assertEqual(code.scope, ['openid', 'email']) + self.assertEqual(code.scope, TokenTestCase.SCOPE_LIST) post_data = self._auth_code_post_data(code=code.code) start_time = time.time() with patch('oidc_provider.lib.utils.token.time.time') as time_func: @@ -661,7 +678,7 @@ def test_additional_idtoken_processing_hook_one_element_in_tuple(self): @override_settings( OIDC_IDTOKEN_PROCESSING_HOOK=[ - 'oidc_provider.tests.app.utils.fake_idtoken_processing_hook', + 'oidc_provider.tests.app.utils.fake_idtoken_processing_hook', ] ) def test_additional_idtoken_processing_hook_one_element_in_list(self): @@ -682,8 +699,8 @@ def test_additional_idtoken_processing_hook_one_element_in_list(self): @override_settings( OIDC_IDTOKEN_PROCESSING_HOOK=[ - 'oidc_provider.tests.app.utils.fake_idtoken_processing_hook', - 'oidc_provider.tests.app.utils.fake_idtoken_processing_hook2', + 'oidc_provider.tests.app.utils.fake_idtoken_processing_hook', + 'oidc_provider.tests.app.utils.fake_idtoken_processing_hook2', ] ) def test_additional_idtoken_processing_hook_two_elements_in_list(self): @@ -754,7 +771,7 @@ def test_additional_idtoken_processing_hook_kwargs(self): kwargs_passed = id_token.get('kwargs_passed_to_processing_hook') assert kwargs_passed self.assertTrue(kwargs_passed.get('token').startswith( - '") self.assertEqual(set(kwargs_passed.keys()), {'token', 'request'}) @@ -797,11 +814,7 @@ def test_client_credentials_grant_type(self): self.client.scope = fake_scopes_list self.client.save() - post_data = { - 'client_id': self.client.client_id, - 'client_secret': self.client.client_secret, - 'grant_type': 'client_credentials', - } + post_data = self._client_credentials_post_data() response = self._post_request(post_data) response_dict = json.loads(response.content.decode('utf-8')) @@ -857,12 +870,85 @@ def test_printing_token_used_by_client_credentials_grant_type(self): self.client.scope = ['something'] self.client.save() - post_data = { - 'client_id': self.client.client_id, - 'client_secret': self.client.client_secret, - 'grant_type': 'client_credentials', - } - response = self._post_request(post_data) + response = self._post_request(self._client_credentials_post_data()) response_dict = json.loads(response.content.decode('utf-8')) token = Token.objects.get(access_token=response_dict['access_token']) self.assertTrue(str(token)) + + @override_settings(OIDC_GRANT_TYPE_PASSWORD_ENABLE=True) + def test_requested_scope(self): + # GRANT_TYPE=PASSWORD + response = self._post_request( + post_data=self._password_grant_post_data(['openid', 'invalid_scope']), + extras=self._password_grant_auth_header() + ) + + response_dict = json.loads(response.content.decode('utf-8')) + + # It should fail when client requested an invalid scope. + self.assertEqual(400, response.status_code) + self.assertEqual('invalid_scope', response_dict['error']) + + # happy path: no scope + response = self._post_request( + post_data=self._password_grant_post_data([]), + extras=self._password_grant_auth_header() + ) + + response_dict = json.loads(response.content.decode('utf-8')) + self.assertEqual(200, response.status_code) + self.assertEqual(TokenTestCase.SCOPE, response_dict['scope']) + + # happy path: single scope + response = self._post_request( + post_data=self._password_grant_post_data(['email']), + extras=self._password_grant_auth_header() + ) + + response_dict = json.loads(response.content.decode('utf-8')) + self.assertEqual(200, response.status_code) + self.assertEqual('email', response_dict['scope']) + + # happy path: multiple scopes + response = self._post_request( + post_data=self._password_grant_post_data(['email', 'openid']), + extras=self._password_grant_auth_header() + ) + + # GRANT_TYPE=CLIENT_CREDENTIALS + response_dict = json.loads(response.content.decode('utf-8')) + self.assertEqual(200, response.status_code) + self.assertEqual('email openid', response_dict['scope']) + + response = self._post_request( + post_data=self._client_credentials_post_data(['openid', 'invalid_scope']) + ) + + response_dict = json.loads(response.content.decode('utf-8')) + + # It should fail when client requested an invalid scope. + self.assertEqual(400, response.status_code) + self.assertEqual('invalid_scope', response_dict['error']) + + # happy path: no scope + response = self._post_request(post_data=self._client_credentials_post_data()) + + response_dict = json.loads(response.content.decode('utf-8')) + self.assertEqual(200, response.status_code) + self.assertEqual(TokenTestCase.SCOPE, response_dict['scope']) + + # happy path: single scope + response = self._post_request(post_data=self._client_credentials_post_data(['email'])) + + response_dict = json.loads(response.content.decode('utf-8')) + self.assertEqual(200, response.status_code) + self.assertEqual('email', response_dict['scope']) + + # happy path: multiple scopes + response = self._post_request( + post_data=self._client_credentials_post_data(['email', 'openid']) + ) + + response_dict = json.loads(response.content.decode('utf-8')) + self.assertEqual(200, response.status_code) + self.assertEqual('email openid', response_dict['scope']) diff --git a/oidc_provider/urls.py b/oidc_provider/urls.py index 44cc9143..08d219f0 100644 --- a/oidc_provider/urls.py +++ b/oidc_provider/urls.py @@ -1,7 +1,4 @@ -try: - from django.urls import url -except ImportError: - from django.conf.urls import url +from django.urls import re_path from django.views.decorators.csrf import csrf_exempt from oidc_provider import ( @@ -11,18 +8,18 @@ app_name = 'oidc_provider' urlpatterns = [ - url(r'^authorize/?$', views.AuthorizeView.as_view(), name='authorize'), - url(r'^token/?$', csrf_exempt(views.TokenView.as_view()), name='token'), - url(r'^userinfo/?$', csrf_exempt(views.userinfo), name='userinfo'), - url(r'^end-session/?$', views.EndSessionView.as_view(), name='end-session'), - url(r'^\.well-known/openid-configuration/?$', views.ProviderInfoView.as_view(), - name='provider-info'), - url(r'^introspect/?$', views.TokenIntrospectionView.as_view(), name='token-introspection'), - url(r'^jwks/?$', views.JwksView.as_view(), name='jwks'), + re_path(r'^authorize/?$', views.AuthorizeView.as_view(), name='authorize'), + re_path(r'^token/?$', csrf_exempt(views.TokenView.as_view()), name='token'), + re_path(r'^userinfo/?$', csrf_exempt(views.userinfo), name='userinfo'), + re_path(r'^end-session/?$', views.EndSessionView.as_view(), name='end-session'), + re_path(r'^\.well-known/openid-configuration/?$', views.ProviderInfoView.as_view(), + name='provider-info'), + re_path(r'^introspect/?$', views.TokenIntrospectionView.as_view(), name='token-introspection'), + re_path(r'^jwks/?$', views.JwksView.as_view(), name='jwks'), ] if settings.get('OIDC_SESSION_MANAGEMENT_ENABLE'): urlpatterns += [ - url(r'^check-session-iframe/?$', views.CheckSessionIframeView.as_view(), - name='check-session-iframe'), + re_path(r'^check-session-iframe/?$', views.CheckSessionIframeView.as_view(), + name='check-session-iframe'), ] diff --git a/oidc_provider/version.py b/oidc_provider/version.py index a71c5c7f..32a90a3b 100644 --- a/oidc_provider/version.py +++ b/oidc_provider/version.py @@ -1 +1 @@ -__version__ = '0.7.0' +__version__ = '0.8.0' diff --git a/oidc_provider/views.py b/oidc_provider/views.py index 4ecd864f..13ca6e32 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -103,20 +103,15 @@ def get(self, request, *args, **kwargs): raise AuthorizeError( authorize.params['redirect_uri'], 'consent_required', authorize.grant_type) - implicit_flow_resp_types = {'id_token', 'id_token token'} - allow_skipping_consent = ( - authorize.client.client_type != 'public' or - authorize.params['response_type'] in implicit_flow_resp_types) - if not authorize.client.require_consent and ( - allow_skipping_consent and + authorize.is_client_allowed_to_skip_consent() and 'consent' not in authorize.params['prompt']): return redirect(authorize.create_response_uri()) if authorize.client.reuse_consent: # Check if user previously give consent. if authorize.client_has_user_consent() and ( - allow_skipping_consent and + authorize.is_client_allowed_to_skip_consent() and 'consent' not in authorize.params['prompt']): return redirect(authorize.create_response_uri()) diff --git a/setup.py b/setup.py index 70663bc4..f20b52b8 100644 --- a/setup.py +++ b/setup.py @@ -31,11 +31,11 @@ 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', 'Topic :: Internet :: WWW/HTTP', 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', ], diff --git a/tox.ini b/tox.ini index b2f8dcfe..088c1390 100644 --- a/tox.ini +++ b/tox.ini @@ -1,31 +1,34 @@ [tox] envlist= docs, - py27-django{111}, - py35-django{111,20,21}, - py36-django{111,20,21}, + py38-django{32,40,41,42}, + py39-django{32,40,41,42}, + py310-django{32,40,41,42}, + py311-django{32,40,41,42}, + flake8 [testenv] changedir= oidc_provider deps = mock - psycopg2 - pytest==3.6.4 + psycopg2-binary + pytest pytest-django pytest-flake8 pytest-cov - django111: django>=1.11,<1.12 - django20: django>=2.0,<2.1 - django21: django>=2.1,<2.2 + django32: django>=3.2,<3.3 + django40: django>=4.0,<4.1 + django41: django>=4.1,<4.2 + django42: django>=4.2,<4.3 commands = - pytest --flake8 --cov=oidc_provider {posargs} + pytest --cov=oidc_provider {posargs} [testenv:docs] -basepython = python2.7 +basepython = python3.11 changedir = docs -whitelist_externals = +allowlist_externals = mkdir deps = sphinx @@ -34,12 +37,13 @@ commands = mkdir -p _static/ sphinx-build -v -W -b html -d {envtmpdir}/doctrees -D html_static_path="_static" . {envtmpdir}/html +[testenv:flake8] +basepython = python3.11 +deps = + flake8 +commands = + flake8 . --exclude=venv/,.tox/,migrations --max-line-length 100 + [pytest] DJANGO_SETTINGS_MODULE = oidc_provider.tests.settings -python_files = test_*.py -flake8-max-line-length = 100 -flake8-ignore = - .git ALL - __pycache__ ALL - .ropeproject ALL - migrations/* ALL +python_files = test_*.py \ No newline at end of file