diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..d970e28b --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: juanifioren 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/.gitignore b/.gitignore index 5c2170d3..1c780810 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ docs/_build/ .python-version .vscode .pytest_cache/ +.coverage* diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..0a0d2837 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,24 @@ +# Read the Docs configuration file for Sphinx projects +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the OS, Python version and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.10" + +# Build documentation in the "docs/" directory with Sphinx +sphinx: + configuration: docs/conf.py + +# Optionally build your docs in additional formats such as PDF and ePub +formats: + - pdf + +# Python requirements required to build your documentation +python: + install: + - requirements: docs/requirements.txt \ No newline at end of file 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..e9daf3a9 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 @@ -13,8 +13,8 @@ OpenID Connect is a simple identity layer on top of the OAuth 2.0 protocol, whic `django-oidc-provider` can help you providing out of the box all the endpoints, data and logic needed to add OpenID Connect (and OAuth2) capabilities to your Django projects. -Support for Python 3 and 2. Also latest versions of django. +Support for Python 3 and latest versions of django. [Read documentation for more info.](http://django-oidc-provider.readthedocs.org/) -[Do you want to contribute? Please read this.](http://django-oidc-provider.readthedocs.io/en/latest/sections/contribute.html) +[Do you want to contribute? Please read this.](http://django-oidc-provider.readthedocs.io/en/master/sections/contribute.html) diff --git a/docs/conf.py b/docs/conf.py index 04d911c0..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,9 +53,9 @@ # 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. diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..856b3f4c --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,2 @@ +sphinx +sphinx_rtd_theme \ No newline at end of file diff --git a/docs/sections/changelog.rst b/docs/sections/changelog.rst index 63a483fb..99b837e6 100644 --- a/docs/sections/changelog.rst +++ b/docs/sections/changelog.rst @@ -8,6 +8,36 @@ All notable changes to this project will be documented in this file. Unreleased ========== + +0.8.2 +===== + +*2023-12-15* + +* Added: Discovery endpoint response caching. Introducing OIDC_DISCOVERY_CACHE_ENABLE. +* Fixed: ResponseType data migration. +* Fixed: correctly verify PKCE secret in token endpoint. + +0.8.1 +===== + +*2023-10-22* + +* 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. +* Fixed: race condition in authorization code, parallel requests may reuse same token. + +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..a804ae61 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/scopesclaims.rst b/docs/sections/scopesclaims.rst index 4bcfacc6..977027f0 100644 --- a/docs/sections/scopesclaims.rst +++ b/docs/sections/scopesclaims.rst @@ -82,7 +82,7 @@ Somewhere in your Django ``settings.py``:: Inside your oidc_provider_settings.py file add the following class:: - from django.utils.translation import gettext as _ + from django.utils.translation import ugettext_lazy as _ from oidc_provider.lib.claims import ScopeClaims class CustomScopeClaims(ScopeClaims): diff --git a/docs/sections/settings.rst b/docs/sections/settings.rst index eae2e8b7..3a7df2cc 100644 --- a/docs/sections/settings.rst +++ b/docs/sections/settings.rst @@ -55,6 +55,20 @@ OPTIONAL. ``int``. Code object expiration after been delivered. Expressed in seconds. Default is ``60*10``. +OIDC_DISCOVERY_CACHE_ENABLE +=========================== + +OPTIONAL. ``bool``. Enable caching the response on the discovery endpoint, by using default cache. Cache key will be a combination of site URL and types supported by the provider, changing any of these will invalidate stored value. + +Default is ``False``. + +OIDC_DISCOVERY_CACHE_EXPIRE +=========================== + +OPTIONAL. ``int``. Discovery endpoint cache expiration time expressed in seconds. + +Expressed in seconds. Default is ``60*10``. + OIDC_EXTRA_SCOPE_CLAIMS ======================= @@ -234,3 +248,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/example/app/urls.py b/example/app/urls.py index 56d10ccd..a32d9d89 100644 --- a/example/app/urls.py +++ b/example/app/urls.py @@ -5,9 +5,9 @@ urlpatterns = [ - re_path(r'^$', TemplateView.as_view(template_name='home.html'), name='home'), - re_path(r'^accounts/login/$', auth_views.LoginView.as_view(template_name='login.html'), name='login'), - re_path(r'^accounts/logout/$', auth_views.LogoutView.as_view(next_page='/'), name='logout'), - re_path(r'^', include('oidc_provider.urls', namespace='oidc_provider')), - re_path(r'^admin/', admin.site.urls), + url(r'^$', TemplateView.as_view(template_name='home.html'), name='home'), + url(r'^accounts/login/$', auth_views.LoginView.as_view(template_name='login.html'), name='login'), # noqa + url(r'^accounts/logout/$', auth_views.LogoutView.as_view(next_page='/'), name='logout'), + url(r'^', include('oidc_provider.urls', namespace='oidc_provider')), + url(r'^admin/', admin.site.urls), ] diff --git a/oidc_provider/admin.py b/oidc_provider/admin.py index c0349998..86a90fc7 100644 --- a/oidc_provider/admin.py +++ b/oidc_provider/admin.py @@ -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/endpoints/authorize.py b/oidc_provider/lib/endpoints/authorize.py index 0b574cfc..4bddc6a3 100644 --- a/oidc_provider/lib/endpoints/authorize.py +++ b/oidc_provider/lib/endpoints/authorize.py @@ -1,15 +1,12 @@ from datetime import timedelta -from hashlib import ( - md5, - sha256, -) +from hashlib import sha256 import logging try: from urllib import urlencode from urlparse import urlsplit, parse_qs, urlunsplit except ImportError: from urllib.parse import urlsplit, parse_qs, urlunsplit, urlencode -from secrets import token_hex +from secrets import token_hex from django.utils import timezone @@ -124,6 +121,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) @@ -131,24 +150,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 @@ -276,6 +284,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 a909a8b6..f98a8786 100644 --- a/oidc_provider/lib/endpoints/token.py +++ b/oidc_provider/lib/endpoints/token.py @@ -1,11 +1,13 @@ -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.db import DatabaseError from django.http import JsonResponse +from oidc_provider import settings from oidc_provider.lib.errors import ( TokenError, UserAuthError, @@ -17,7 +19,6 @@ Code, Token, ) -from oidc_provider import settings logger = logging.getLogger(__name__) @@ -69,22 +70,29 @@ def validate_params(self): raise TokenError('invalid_client') try: - self.code = Code.objects.get(code=self.params['code']) + self.code = Code.objects.select_for_update(nowait=True).get( + code=self.params['code']) + except DatabaseError: + logger.debug('[Token] Code cannot be reused: %s', self.params['code']) + raise TokenError('invalid_grant') except Code.DoesNotExist: logger.debug('[Token] Code does not exist: %s', self.params['code']) raise TokenError('invalid_grant') if not (self.code.client == self.client) \ - or self.code.has_expired(): + or self.code.has_expired(): logger.debug('[Token] Invalid code: invalid client or code has expired') raise TokenError('invalid_grant') # Validate PKCE parameters. - if self.params['code_verifier']: + if self.code.code_challenge: + if self.params['code_verifier'] is None: + raise TokenError('invalid_grant') + 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'] @@ -134,6 +142,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() @@ -144,46 +173,23 @@ def create_response_dic(self): elif self.params['grant_type'] == 'client_credentials': return self.create_client_credentials_response_dic() - def create_access_token_response_dic(self): - # See https://tools.ietf.org/html/rfc6749#section-4.3 - + def create_token(self, user, client, scope): token = create_token( - self.user, - self.client, - self.params['scope'].split(' ')) - - create_id_token_hook = settings.import_hook( - 'OIDC_IDTOKEN_CREATE_HOOK' + user=user, + client=client, + scope=scope, ) - id_token_dic = create_id_token_hook( - token=token, - user=self.user, - aud=self.client.client_id, - nonce='self.code.nonce', - at_hash=token.at_hash, - request=self.request, - scope=token.scope, - ) - - token.id_token = id_token_dic - token.save() - - return { - 'access_token': token.access_token, - 'refresh_token': token.refresh_token, - 'expires_in': settings.get('OIDC_TOKEN_EXPIRE'), - 'token_type': 'bearer', - 'id_token': self._encode_id_token(id_token_dic, token.client), - } + 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, + ) create_id_token_hook = settings.import_hook( 'OIDC_IDTOKEN_CREATE_HOOK' @@ -228,10 +234,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. create_id_token_hook = settings.import_hook( @@ -267,21 +274,58 @@ def create_refresh_response_dic(self): return dic + def create_access_token_response_dic(self): + # See https://tools.ietf.org/html/rfc6749#section-4.3 + + token_scopes = self.validate_requested_scopes() + token = self.create_token( + self.user, + self.client, + token_scopes, + ) + + create_id_token_hook = settings.import_hook( + 'OIDC_IDTOKEN_CREATE_HOOK' + ) + + id_token_dic = create_id_token_hook( + token=token, + user=self.user, + aud=self.client.client_id, + nonce='self.code.nonce', + at_hash=token.at_hash, + request=self.request, + scope=token.scope, + ) + + token.id_token = id_token_dic + token.save() + + return { + 'access_token': token.access_token, + 'refresh_token': token.refresh_token, + 'expires_in': settings.get('OIDC_TOKEN_EXPIRE'), + 'token_type': 'bearer', + 'id_token': self._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/migrations/0016_userconsent_and_verbosenames.py b/oidc_provider/migrations/0016_userconsent_and_verbosenames.py index 5396e007..fc562414 100644 --- a/oidc_provider/migrations/0016_userconsent_and_verbosenames.py +++ b/oidc_provider/migrations/0016_userconsent_and_verbosenames.py @@ -21,7 +21,7 @@ class Migration(migrations.Migration): model_name='userconsent', name='date_given', field=models.DateTimeField( - default=datetime.datetime(2016, 6, 10, 17, 53, 48, 889808, tzinfo=timezone.utc), verbose_name='Date Given'), + default=datetime.datetime(2016, 6, 10, 17, 53, 48, 889808, tzinfo=datetime.timezone.utc), verbose_name='Date Given'), preserve_default=False, ), migrations.AlterField( diff --git a/oidc_provider/migrations/0026_client_multiple_response_types.py b/oidc_provider/migrations/0026_client_multiple_response_types.py index f572f0c7..067d2910 100644 --- a/oidc_provider/migrations/0026_client_multiple_response_types.py +++ b/oidc_provider/migrations/0026_client_multiple_response_types.py @@ -16,10 +16,11 @@ def migrate_response_type(apps, schema_editor): # importing directly yields the latest without response_type ResponseType = apps.get_model('oidc_provider', 'ResponseType') Client = apps.get_model('oidc_provider', 'Client') + db = schema_editor.connection.alias for value, description in RESPONSE_TYPES: - ResponseType.objects.create(value=value, description=description) - for client in Client.objects.all(): - client.response_types.add(ResponseType.objects.get(value=client.response_type)) + ResponseType.objects.using(db).create(value=value, description=description) + for client in Client.objects.using(db).all(): + client.response_types.add(ResponseType.objects.using(db).get(value=client.response_type)) class Migration(migrations.Migration): diff --git a/oidc_provider/models.py b/oidc_provider/models.py index 43444332..506fa693 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 @@ -264,4 +263,6 @@ def __unicode__(self): @property def kid(self): - return u'{0}'.format(md5(self.key.encode('utf-8'), usedforsecurity=False).hexdigest() if self.key else '') + return u'{0}'.format( + md5(self.key.encode('utf-8'), usedforsecurity=False).hexdigest() if self.key else '' + ) diff --git a/oidc_provider/settings.py b/oidc_provider/settings.py index 04062c38..9d30759f 100644 --- a/oidc_provider/settings.py +++ b/oidc_provider/settings.py @@ -14,7 +14,7 @@ def __init__(self): @property def OIDC_LOGIN_URL(self): """ - REQUIRED. Used to log the user in. By default Django's LOGIN_URL will be used. + OPTIONAL. Used to log the user in. By default Django's LOGIN_URL will be used. """ return settings.LOGIN_URL @@ -57,6 +57,20 @@ def OIDC_CODE_EXPIRE(self): """ return 60*10 + @property + def OIDC_DISCOVERY_CACHE_ENABLE(self): + """ + OPTIONAL. Enable caching the response on the discovery endpoint. + """ + return False + + @property + def OIDC_DISCOVERY_CACHE_EXPIRE(self): + """ + OPTIONAL. Discovery endpoint cache expiration time expressed in seconds. + """ + return 60*60*24 + @property def OIDC_EXTRA_SCOPE_CLAIMS(self): """ @@ -193,6 +207,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 1b9d766f..ba3d5e50 100644 --- a/oidc_provider/signals.py +++ b/oidc_provider/signals.py @@ -2,8 +2,5 @@ from django.dispatch import Signal -# Signal provides the arguments 'user', 'client', 'scope' user_accept_consent = Signal() - -# Signal provides the arguments 'user', 'client', 'scope' 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 69f23d07..a7f8c943 100644 --- a/oidc_provider/tests/app/urls.py +++ b/oidc_provider/tests/app/urls.py @@ -1,5 +1,10 @@ from django.contrib.auth import views as auth_views -from django.urls import re_path, include + +try: + from django.urls import include, re_path +except ImportError: + 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 @@ -7,9 +12,11 @@ urlpatterns = [ 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'), + 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'), + 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 cfcc6b11..61dd66a6 100644 --- a/oidc_provider/tests/cases/test_authorize_endpoint.py +++ b/oidc_provider/tests/cases/test_authorize_endpoint.py @@ -1,5 +1,3 @@ -from oidc_provider.lib.errors import RedirectUriError - try: from urllib.parse import urlencode, quote except ImportError: @@ -25,15 +23,16 @@ from jwkest.jwt import JWT from oidc_provider import settings +from oidc_provider.lib.endpoints.authorize import AuthorizeEndpoint +from oidc_provider.lib.errors import RedirectUriError +from oidc_provider.lib.utils.authorize import strip_prompt_login from oidc_provider.tests.app.utils import ( create_fake_user, create_fake_client, FAKE_CODE_CHALLENGE, is_code_valid, ) -from oidc_provider.lib.utils.authorize import strip_prompt_login from oidc_provider.views import AuthorizeView -from oidc_provider.lib.endpoints.authorize import AuthorizeEndpoint class AuthorizeEndpointMixin(object): @@ -276,15 +275,16 @@ def test_response_uri_is_properly_constructed(self): parsed = urlsplit(response['Location']) params = parse_qs(parsed.query or parsed.fragment) state = params['state'][0] - assert self.state == state, "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) assert is_code_ok, 'Code returned is invalid or missing' - assert set(params.keys()) == {'state', 'code'}, \ - 'More than state or code appended as query params' + self.assertEqual( + set(params.keys()), {'state', 'code'}, + msg='More than state or code appended as query params') assert response['Location'].startswith(self.client.default_redirect_uri), \ 'Different redirect_uri returned' @@ -660,7 +660,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') - assert 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 4c4093a2..8da66b76 100644 --- a/oidc_provider/tests/cases/test_claims.py +++ b/oidc_provider/tests/cases/test_claims.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals from django.test import TestCase + from django.utils.translation import override as override_language from oidc_provider.lib.claims import ScopeClaims, StandardScopeClaims, STANDARD_CLAIMS @@ -48,12 +49,15 @@ def test_clean_dic(self): 'phone_number': '', } clean_dict = self.scopeClaims._clean_dic(dict_to_clean) - assert clean_dict == { - 'family_name': 'Doe', - 'given_name': 'John', - 'name': 'John Doe', - 'email': u'johndoe@example.com', - } + self.assertEqual( + clean_dict, + { + 'family_name': 'Doe', + 'given_name': 'John', + 'name': 'John Doe', + 'email': u'johndoe@example.com' + } + ) def test_locale(self): with override_language('fr'): diff --git a/oidc_provider/tests/cases/test_commands.py b/oidc_provider/tests/cases/test_commands.py index 792e47f0..2f9248fe 100644 --- a/oidc_provider/tests/cases/test_commands.py +++ b/oidc_provider/tests/cases/test_commands.py @@ -1,7 +1,9 @@ from io import StringIO + from django.core.management import call_command from django.test import TestCase + class CommandsTest(TestCase): def test_creatersakey_output(self): diff --git a/oidc_provider/tests/cases/test_introspection_endpoint.py b/oidc_provider/tests/cases/test_introspection_endpoint.py index 3e1e2762..34a8ac73 100644 --- a/oidc_provider/tests/cases/test_introspection_endpoint.py +++ b/oidc_provider/tests/cases/test_introspection_endpoint.py @@ -44,7 +44,7 @@ def setUp(self): self.token.save() def _assert_inactive(self, response): - assert response.status_code == 200 + self.assertEqual(response.status_code, 200) self.assertJSONEqual(force_str(response.content), {'active': False}) def _assert_active(self, response, **kwargs): @@ -130,3 +130,8 @@ def test_valid_client_grant_token_without_aud_validation(self): '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 598b7dd2..17339285 100644 --- a/oidc_provider/tests/cases/test_middleware.py +++ b/oidc_provider/tests/cases/test_middleware.py @@ -1,7 +1,8 @@ +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: diff --git a/oidc_provider/tests/cases/test_provider_info_endpoint.py b/oidc_provider/tests/cases/test_provider_info_endpoint.py index 2265ef66..1dfe2777 100644 --- a/oidc_provider/tests/cases/test_provider_info_endpoint.py +++ b/oidc_provider/tests/cases/test_provider_info_endpoint.py @@ -1,9 +1,13 @@ +from mock import patch + +from django.core.cache import cache try: from django.urls import reverse except ImportError: from django.core.urlresolvers import reverse from django.test import RequestFactory -from django.test import TestCase +from django.test import TestCase, override_settings + from oidc_provider.views import ProviderInfoView @@ -13,7 +17,11 @@ class ProviderInfoTestCase(TestCase): def setUp(self): self.factory = RequestFactory() - def test_response(self): + def tearDown(self): + cache.clear() + + @patch('oidc_provider.views.ProviderInfoView._build_cache_key') + def test_response(self, build_cache_key): """ See if the endpoint is returning the corresponding server information by checking status, content type, etc. @@ -24,6 +32,32 @@ def test_response(self): response = ProviderInfoView.as_view()(request) + # Caching not available by default. + build_cache_key.assert_not_called() + + self.assertEqual(response.status_code, 200) + self.assertEqual(response['Content-Type'] == 'application/json', True) + self.assertEqual(bool(response.content), True) + + @override_settings(OIDC_DISCOVERY_CACHE_ENABLE=True) + @patch('oidc_provider.views.ProviderInfoView._build_cache_key') + def test_response_with_cache_enabled(self, build_cache_key): + """ + Enable caching on the discovery endpoint and ensure data is being saved on cache. + """ + build_cache_key.return_value = 'key' + + url = reverse('oidc_provider:provider-info') + + request = self.factory.get(url) + + response = ProviderInfoView.as_view()(request) + self.assertEqual(response.status_code, 200) + build_cache_key.assert_called_once() + + assert 'authorization_endpoint' in cache.get('key') + + response = ProviderInfoView.as_view()(request) self.assertEqual(response.status_code, 200) self.assertEqual(response['Content-Type'] == 'application/json', True) self.assertEqual(bool(response.content), True) diff --git a/oidc_provider/tests/cases/test_token_endpoint.py b/oidc_provider/tests/cases/test_token_endpoint.py index dab90e76..8990d3d2 100644 --- a/oidc_provider/tests/cases/test_token_endpoint.py +++ b/oidc_provider/tests/cases/test_token_endpoint.py @@ -4,6 +4,8 @@ from base64 import b64encode +from django.db import DatabaseError + try: from urllib.parse import urlencode except ImportError: @@ -11,6 +13,7 @@ from django.core.management import call_command from django.http import JsonResponse + try: from django.urls import reverse except ImportError: @@ -51,6 +54,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 +69,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 +107,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 +142,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 +242,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) @@ -295,6 +314,24 @@ def test_authorization_code(self): self.assertEqual(id_token['sub'], str(self.user.id)) self.assertEqual(id_token['aud'], self.client.client_id) + @override_settings(OIDC_TOKEN_EXPIRE=720) + def test_authorization_code_cant_be_reused(self): + """ + Authorization codes MUST be short lived and single-use, + as described in Section 10.5 of OAuth 2.0 [RFC6749]. + """ + code = self._create_code() + post_data = self._auth_code_post_data(code=code.code) + + with patch('django.db.models.query.QuerySet.select_for_update') as select_for_update_func: + select_for_update_func.side_effect = DatabaseError() + response = self._post_request(post_data) + select_for_update_func.assert_called_once() + + self.assertEqual(response.status_code, 400) + response_dic = json.loads(response.content.decode('utf-8')) + self.assertEqual(response_dic['error'], 'invalid_grant') + @override_settings(OIDC_TOKEN_EXPIRE=720, OIDC_IDTOKEN_INCLUDE_CLAIMS=True) def test_scope_is_ignored_for_auth_code(self): @@ -361,7 +398,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 +698,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 +719,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 +791,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'}) @@ -787,7 +824,26 @@ def test_pkce_parameters(self): response = self._post_request(post_data) - json.loads(response.content.decode('utf-8')) + self.assertIn('access_token', json.loads(response.content.decode('utf-8'))) + + def test_pkce_missing_code_verifier(self): + """ + Test that a request to the token endpoint without the PKCE parameter + fails when PKCE was used during the authorization request. + """ + + 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) + + assert 'code_verifier' not in post_data + + response = self._post_request(post_data) + + assert json.loads(response.content.decode('utf-8')).get('error') == 'invalid_grant' @override_settings(OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE=False) def test_client_credentials_grant_type(self): @@ -797,11 +853,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 +909,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 bee9eda8..08d219f0 100644 --- a/oidc_provider/urls.py +++ b/oidc_provider/urls.py @@ -1,4 +1,4 @@ -from django.urls import re_path +from django.urls import re_path from django.views.decorators.csrf import csrf_exempt from oidc_provider import ( @@ -13,7 +13,7 @@ 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'), + name='provider-info'), re_path(r'^introspect/?$', views.TokenIntrospectionView.as_view(), name='token-introspection'), re_path(r'^jwks/?$', views.JwksView.as_view(), name='jwks'), ] @@ -21,5 +21,5 @@ if settings.get('OIDC_SESSION_MANAGEMENT_ENABLE'): urlpatterns += [ re_path(r'^check-session-iframe/?$', views.CheckSessionIframeView.as_view(), - name='check-session-iframe'), + name='check-session-iframe'), ] diff --git a/oidc_provider/version.py b/oidc_provider/version.py index 22efd501..10cf218a 100644 --- a/oidc_provider/version.py +++ b/oidc_provider/version.py @@ -1 +1 @@ -__version__ = '0.7.1+orm' +__version__ = '0.8.2+orm' diff --git a/oidc_provider/views.py b/oidc_provider/views.py index 6ff13dfb..7801444d 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -1,3 +1,4 @@ +import hashlib import logging from django.views.decorators.csrf import csrf_exempt @@ -18,7 +19,9 @@ from django.urls import reverse except ImportError: from django.core.urlresolvers import reverse +from django.db import transaction from django.contrib.auth import logout as django_user_logout +from django.core.cache import cache from django.http import JsonResponse, HttpResponse from django.shortcuts import render from django.template.loader import render_to_string @@ -103,20 +106,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()) @@ -169,7 +167,7 @@ def get(self, request, *args, **kwargs): return redirect(uri) def post(self, request, *args, **kwargs): - authorize = AuthorizeEndpoint(request) + authorize = self.authorize_endpoint_class(request) try: authorize.validate_params() @@ -203,20 +201,22 @@ def post(self, request, *args, **kwargs): class TokenView(View): + token_endpoint_class = TokenEndpoint + def post(self, request, *args, **kwargs): - token = TokenEndpoint(request) + token = self.token_endpoint_class(request) try: - token.validate_params() + with transaction.atomic(): + token.validate_params() + dic = token.create_response_dic() - dic = token.create_response_dic() - - return TokenEndpoint.response(dic) + return self.token_endpoint_class.response(dic) except TokenError as error: - return TokenEndpoint.response(error.create_dict(), status=400) + return self.token_endpoint_class.response(error.create_dict(), status=400) except UserAuthError as error: - return TokenEndpoint.response(error.create_dict(), status=403) + return self.token_endpoint_class.response(error.create_dict(), status=403) @require_http_methods(['GET', 'POST', 'OPTIONS']) @@ -258,7 +258,16 @@ def set_headers(response): class ProviderInfoView(View): - def get(self, request, *args, **kwargs): + _types_supported = None + + @property + def types_supported(self): + if self._types_supported is None: + self._types_supported = [ + response_type.value for response_type in ResponseType.objects.all()] + return self._types_supported + + def _build_response_dict(self, request): dic = dict() site_url = get_site_url(request=request) @@ -270,8 +279,7 @@ def get(self, request, *args, **kwargs): dic['end_session_endpoint'] = site_url + reverse('oidc_provider:end-session') dic['introspection_endpoint'] = site_url + reverse('oidc_provider:token-introspection') - types_supported = [response_type.value for response_type in ResponseType.objects.all()] - dic['response_types_supported'] = types_supported + dic['response_types_supported'] = self.types_supported dic['jwks_uri'] = site_url + reverse('oidc_provider:jwks') @@ -286,7 +294,29 @@ def get(self, request, *args, **kwargs): if settings.get('OIDC_SESSION_MANAGEMENT_ENABLE'): dic['check_session_iframe'] = site_url + reverse('oidc_provider:check-session-iframe') - response = JsonResponse(dic) + return dic + + def _build_cache_key(self, request): + """ + Cache key will be a combination of site URL and types supported by the provider. + """ + key_data = get_site_url(request=request) + ''.join(self.types_supported) + key_hash = hashlib.md5(key_data.encode('utf-8')).hexdigest() + return f'oidc_discovery_{key_hash}' + + def get(self, request): + if settings.get('OIDC_DISCOVERY_CACHE_ENABLE'): + cache_key = self._build_cache_key(request) + cached_dict = cache.get(cache_key) + if cached_dict: + response_dict = cached_dict + else: + response_dict = self._build_response_dict(request) + cache.set(cache_key, response_dict, settings.get('OIDC_DISCOVERY_CACHE_EXPIRE')) + else: + response_dict = self._build_response_dict(request) + + response = JsonResponse(response_dict) response['Access-Control-Allow-Origin'] = '*' return response @@ -362,16 +392,18 @@ def get(self, request, *args, **kwargs): class TokenIntrospectionView(View): + token_instrospection_endpoint_class = TokenIntrospectionEndpoint + @method_decorator(csrf_exempt) def dispatch(self, request, *args, **kwargs): return super(TokenIntrospectionView, self).dispatch(request, *args, **kwargs) def post(self, request, *args, **kwargs): - introspection = TokenIntrospectionEndpoint(request) + introspection = self.token_instrospection_endpoint_class(request) try: introspection.validate_params() dic = introspection.create_response_dic() - return TokenIntrospectionEndpoint.response(dic) + return self.token_instrospection_endpoint_class.response(dic) except TokenIntrospectionError: - return TokenIntrospectionEndpoint.response({'active': False}) + return self.token_instrospection_endpoint_class.response({'active': False}) 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 0f54276d..088c1390 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,11 @@ [tox] envlist= docs, - py310-django{32} + 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= @@ -9,19 +13,20 @@ changedir= deps = mock psycopg2-binary - pytest==7.2.2 - pytest-flake8==1.0.7, - pytest-cov==4.0.0 - pytest-dotenv==0.5.2 - pytest-django==4.5.2 - pytest-xdist==3.2.1 - django32: django==3.2.18 + pytest + pytest-django + pytest-flake8 + pytest-cov + 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 --cov=oidc_provider {posargs} [testenv:docs] -basepython = python3.10 +basepython = python3.11 changedir = docs allowlist_externals = mkdir @@ -32,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